Skip to content

feat(persistence): server persistence + client browser-refresh durability - #984

Merged
jherr merged 76 commits into
mainfrom
feat/persistence-core
Jul 27, 2026
Merged

feat(persistence): server persistence + client browser-refresh durability#984
jherr merged 76 commits into
mainfrom
feat/persistence-core

Conversation

@AlemTuzlak

@AlemTuzlak AlemTuzlak commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🎯 Changes

Adds durable chat state on the server and full-page-reload durability on the client, on top of the shipped ephemeral interrupts (#970), resumable streams (#955), and the shared Scope type (#980). Uses #785 as reference; #987 (generation persistence) and #988 (sandbox persistence) stack on this branch.

Server: one new package, @tanstack/ai-persistence

It ships the contract, not backends — four store interfaces (MessageStore, RunStore, InterruptStore, MetadataStore), the withPersistence / withGenerationPersistence middleware, reconstructChat for server-side hydrate, an in-memory reference backend, and a conformance testkit.

An earlier revision of this branch also published -drizzle, -prisma, and -cloudflare packages. Those were removed in 62c9975: a backend package has to own DDL, which puts it in permanent conflict with the migration tooling the consuming app already runs. You implement the stores against your own database instead — the guide and the agent skills walk through it per stack, and the conformance suite is what proves an adapter correct.

Locks live in @tanstack/ai, not in the persistence package

Multi-instance coordination started out inside @tanstack/ai-persistence and moved to core in cd8b731. LockStore / InMemoryLockStore / LocksCapability / withLocks are exported from @tanstack/ai.

The split is deliberate: state stores answer "what is durable chat data?", locks answer "who may run this critical section right now?" Locks are not a stores key, are not composable via composePersistence, and are not covered by the conformance testkit. withPersistence does not lock a turn for you. Keeping them in core also means the consumer that actually needs them today — @tanstack/ai-sandbox, which reads LocksCapability off the middleware chain — doesn't drag in a persistence dependency to get mutual exclusion.

InMemoryLockStore is a per-key promise chain, correct within a single process only; multi-instance deployments implement the one-method LockStore interface themselves, and the Cloudflare Durable Object recipe walks through a lease-based one. New guide at docs/advanced/locks.md.

Client: browser-refresh durability

The persistence option now stores one combined { messages, resume? } record per chat id, so a full page reload restores the transcript, rehydrates pending interrupts, and rejoins an in-flight run via joinRun on a durability-backed connection. Adds localStoragePersistence / sessionStoragePersistence / indexedDBPersistence, and an object form { store, messages: false } that keeps large transcripts off the client while still persisting the resume pointer.

Because it rides the existing option, all six framework integrations get it with no framework-specific code.

Breaking

  • The chat hooks no longer accept id — a hook's identity is its threadId (usechat-threadid-identity). ChatClient keeps id as a lower-level escape hatch.
  • Hand-rolled persistence adapters must update their write path. setItem now receives the combined { messages, resume? } record instead of a bare UIMessage[]. getItem still reads legacy arrays, but an adapter that assumed an array will write the new shape and fail to parse it back — and because adapter reads are best-effort, the conversation silently does not restore. This bit the e2e harness in this very PR; docs/persistence/client-persistence.md now documents the round-trip.

Docs, skills, example, e2e

  • New docs/persistence/ section: overview, chat persistence, client persistence, controls, build-your-own-adapter, migrations, internals. Replaces docs/chat/persistence.md. Locks get their own page under advanced, docs/advanced/locks.md.
  • @tanstack/ai-persistence ships 7 agent skills, nested ai-core-style under skills/ai-persistence/ — an entry point, server, stores, and four build-*-adapter recipes that read the app's existing ORM config and schema and write a single chat-persistence.ts against it (Drizzle, Prisma, Cloudflare D1, and a custom recipe for raw pg / Kysely / Mongo / Supabase).
  • Two skills ship in @tanstack/ai instead, because that's where the code they teach lives: ai-core/client-persistence (browser persistence is in the framework packages — an app persisting only in the browser never installs the server package) and ai-core/locks. Also fixes three packages (ai-mcp, ai-memory, ai-sandbox) whose skills TanStack Intent could never load.
  • examples/ts-react-chat /persistent-chat route (SQLite server + localStorage client), with sqlite-persistence.ts as the runnable reference the docs point at.
  • E2E: persistence-durability.spec.ts (message restore after reload, interrupt survives reload) plus localStorage persistence cases in chat.spec.ts.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

test:pr green on the current head. Full Playwright suite run locally: 385 passed, 1 skipped. Three tools-test specs failed only under unbounded local parallelism and pass at CI's --workers=4; Nx separately flagged @tanstack/ai-sandbox-local-process:test:lib as a flaky task and it passes on re-run.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.

Ten changesets on this branch: persistence-packages and fresh-client-tail (minor, @tanstack/ai-persistence), client-browser-refresh-durability and usechat-threadid-identity (minor, ai-client + all six frameworks), seamless-reload-resume and fast-fail-rejoin (@tanstack/ai), locks-to-core (minor, ai / ai-persistence / ai-sandbox), memorystream-agent-loop-delivery and rejoin-not-aborted-on-mount (patch), and skill-discoverability (patch, ai / ai-mcp / ai-memory / ai-sandbox). No majors — the two breaking changes above land in pre-1.0 minors.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds durable client chat persistence, server-side transcript/run/interrupt persistence, Drizzle, Prisma, and Cloudflare backends, browser storage adapters, resumable run rejoining, migration tooling, framework exports, documentation, examples, and end-to-end browser-refresh coverage.

Changes

Persistence durability

Layer / File(s) Summary
Client persistence and run rejoin
packages/ai-client/*
Persists combined transcript and resume state, supports legacy records, browser storage adapters, and joinRun reattachment.
Server persistence contracts and middleware
packages/ai-persistence/*
Adds store contracts, middleware lifecycle handling, interrupt recovery, composition, in-memory stores, and chat reconstruction.
Persistence backend adapters
packages/ai-persistence-drizzle/*, packages/ai-persistence-prisma/*, packages/ai-persistence-cloudflare/*
Adds SQL and Cloudflare persistence implementations, schemas, migration CLIs, Durable Object locks, and conformance coverage.
Examples and browser durability tests
examples/ts-react-chat/*, testing/e2e/*
Adds persistent chat examples and deterministic reload tests for transcript and interrupt restoration.
Documentation and release wiring
docs/persistence/*, packages/ai-*/src/index.ts, .changeset/*
Documents persistence configuration and publishes new client and backend APIs across framework packages.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • TanStack/ai#661 — Earlier client persistence and hydration changes.
  • TanStack/ai#955 — Resumable stream delivery used for in-flight run reattachment.
  • TanStack/ai#970 — Interrupt and resume protocol changes used by persisted resume state.

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant ChatClient
  participant Storage
  participant Server
  participant Persistence
  Browser->>ChatClient: Reload chat
  ChatClient->>Storage: Read persisted transcript and resume snapshot
  ChatClient->>Server: GET thread or joinRun(runId)
  Server->>Persistence: Load thread/run/interrupt state
  Persistence-->>Server: Stored state
  Server-->>ChatClient: Transcript or replayed stream chunks
  ChatClient-->>Browser: Restore messages and interrupts
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: server persistence plus client browser-refresh durability.
Description check ✅ Passed The description matches the template with Changes, Checklist, and Release Impact sections and includes the required details.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/persistence-core

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

socket-security Bot commented Jul 22, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm better-sqlite3 is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: pnpm-lock.yamlnpm/@tanstack/nitro-v2-vite-plugin@1.155.0npm/nitro@3.0.260610-betanpm/@tanstack/start@1.120.20npm/better-sqlite3@12.11.1

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/better-sqlite3@12.11.1. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm drizzle-orm is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: pnpm-lock.yamlnpm/@tanstack/nitro-v2-vite-plugin@1.155.0npm/nitro@3.0.260610-betanpm/@tanstack/start@1.120.20npm/drizzle-orm@0.45.2

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/drizzle-orm@0.45.2. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm effect is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: pnpm-lock.yamlnpm/@tanstack/nitro-v2-vite-plugin@1.155.0npm/nitro@3.0.260610-betanpm/@tanstack/start@1.120.20npm/effect@3.20.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/effect@3.20.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm robust-predicates is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: pnpm-lock.yamlnpm/@tanstack/nitro-v2-vite-plugin@1.155.0npm/nitro@3.0.260610-betanpm/@tanstack/start@1.120.20npm/robust-predicates@3.0.3

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/robust-predicates@3.0.3. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Medium
Deprecated by its maintainer: npm prebuild-install

Reason: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.

From: pnpm-lock.yamlnpm/@tanstack/nitro-v2-vite-plugin@1.155.0npm/nitro@3.0.260610-betanpm/@tanstack/start@1.120.20npm/prebuild-install@7.1.3

ℹ Read more on: This package | This alert | What is a deprecated package?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Research the state of the package and determine if there are non-deprecated versions that can be used, or if it should be replaced with a new, supported solution.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/prebuild-install@7.1.3. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🚀 Changeset Version Preview

16 package(s) bumped directly, 34 bumped as dependents.

🟥 Major bumps

Package Version Reason
@tanstack/ai-angular 0.3.1 → 1.0.0 Changeset
@tanstack/ai-durable-stream 0.0.0 → 1.0.0 Changeset
@tanstack/ai-memory 0.0.0 → 1.0.0 Changeset
@tanstack/ai-openrouter 0.15.10 → 1.0.0 Changeset
@tanstack/ai-persistence 0.0.0 → 1.0.0 Changeset
@tanstack/ai-preact 0.11.1 → 1.0.0 Changeset
@tanstack/ai-react 0.18.1 → 1.0.0 Changeset
@tanstack/ai-sandbox 0.2.4 → 1.0.0 Changeset
@tanstack/ai-solid 0.15.1 → 1.0.0 Changeset
@tanstack/ai-svelte 0.15.1 → 1.0.0 Changeset
@tanstack/ai-vue 0.15.1 → 1.0.0 Changeset
@tanstack/ai-acp 0.2.3 → 1.0.0 Dependent
@tanstack/ai-anthropic 0.16.3 → 1.0.0 Dependent
@tanstack/ai-bedrock 0.1.4 → 1.0.0 Dependent
@tanstack/ai-claude-code 0.2.3 → 1.0.0 Dependent
@tanstack/ai-code-mode 0.3.8 → 1.0.0 Dependent
@tanstack/ai-code-mode-skills 0.3.11 → 1.0.0 Dependent
@tanstack/ai-codex 0.2.3 → 1.0.0 Dependent
@tanstack/ai-elevenlabs 0.2.34 → 1.0.0 Dependent
@tanstack/ai-fal 0.9.12 → 1.0.0 Dependent
@tanstack/ai-gemini 0.20.1 → 1.0.0 Dependent
@tanstack/ai-grok 0.14.9 → 1.0.0 Dependent
@tanstack/ai-grok-build 0.2.3 → 1.0.0 Dependent
@tanstack/ai-groq 0.5.3 → 1.0.0 Dependent
@tanstack/ai-isolate-node 0.1.47 → 1.0.0 Dependent
@tanstack/ai-isolate-quickjs 0.1.47 → 1.0.0 Dependent
@tanstack/ai-mistral 0.2.3 → 1.0.0 Dependent
@tanstack/ai-ollama 0.8.16 → 1.0.0 Dependent
@tanstack/ai-openai 0.17.1 → 1.0.0 Dependent
@tanstack/ai-opencode 0.2.3 → 1.0.0 Dependent
@tanstack/ai-react-ui 0.8.15 → 1.0.0 Dependent
@tanstack/ai-sandbox-cloudflare 0.2.4 → 1.0.0 Dependent
@tanstack/ai-sandbox-daytona 0.2.0 → 1.0.0 Dependent
@tanstack/ai-sandbox-docker 0.2.0 → 1.0.0 Dependent
@tanstack/ai-sandbox-local-process 0.2.0 → 1.0.0 Dependent
@tanstack/ai-sandbox-sprites 0.2.1 → 1.0.0 Dependent
@tanstack/ai-sandbox-vercel 0.2.0 → 1.0.0 Dependent
@tanstack/ai-solid-ui 0.7.14 → 1.0.0 Dependent
@tanstack/openai-base 0.9.9 → 1.0.0 Dependent

🟨 Minor bumps

Package Version Reason
@tanstack/ai 0.42.0 → 0.43.0 Changeset
@tanstack/ai-client 0.22.1 → 0.23.0 Changeset
@tanstack/ai-devtools-core 0.4.24 → 0.5.0 Changeset
@tanstack/ai-event-client 0.6.8 → 0.7.0 Changeset

🟩 Patch bumps

Package Version Reason
@tanstack/ai-mcp 0.2.5 → 0.2.6 Changeset
@tanstack/ai-isolate-cloudflare 0.2.38 → 0.2.39 Dependent
@tanstack/ai-vue-ui 0.2.34 → 0.2.35 Dependent
@tanstack/preact-ai-devtools 0.1.67 → 0.1.68 Dependent
@tanstack/react-ai-devtools 0.2.67 → 0.2.68 Dependent
@tanstack/solid-ai-devtools 0.2.67 → 0.2.68 Dependent
ag-ui 0.0.2 → 0.0.3 Dependent

@nx-cloud

nx-cloud Bot commented Jul 22, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 043327c

Command Status Duration Result
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 23s View ↗

☁️ Nx Cloud last updated this comment at 2026-07-27 16:20:23 UTC

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/ai-persistence-cloudflare/tests/migration-cli.test.ts (1)

1-66: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Colocate this test with migration-cli.ts.

Move this to packages/ai-persistence-cloudflare/src/migration-cli.test.ts so its location follows the repository test-layout rule.

As per coding guidelines, “Place unit tests in *.test.ts files alongside the source they cover.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence-cloudflare/tests/migration-cli.test.ts` around lines
1 - 66, Move the migration CLI test suite from the tests directory to a
*.test.ts file alongside migration-cli.ts under src, preserving its existing
imports, test cases, and behavior.

Source: Coding guidelines

packages/ai-client/src/chat-client.ts (1)

1-1: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

IndexedDB rejoin needs an async callback path
readInitial() only computes rejoinRunId on the synchronous path. For promise-backed persistence, hydrateAsync() reapplies the resume snapshot but never calls resumeInFlightRun(), so reloads from indexedDBPersistence won’t reattach to an in-flight run. Thread a rejoin callback through the async hydration path and invoke it once the processor is ready.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-client/src/chat-client.ts` at line 1, Update the async hydration
flow around readInitial() and hydrateAsync() to carry the computed rejoinRunId
through promise-backed persistence, then invoke resumeInFlightRun() once the
processor is ready. Preserve the existing synchronous rejoin behavior and ensure
the callback runs only after the resume snapshot has been reapplied.
🟡 Minor comments (10)
examples/ts-react-chat/src/routes/persistent-chat.tsx-20-23 (1)

20-23: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restore Date fields when hydrating persisted chat state. JSON.stringify converts Date values to strings and JSON.parse does not revive them, so rehydrated messages no longer match the ChatPersistedState runtime shape.

  • examples/ts-react-chat/src/routes/persistent-chat.tsx#L20-L23: use a matching serializer/reviver that restores persisted message dates.
  • testing/e2e/src/routes/persistence-durability.tsx#L25-L28: use the same codec and assert a rehydrated timestamp remains a Date.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/ts-react-chat/src/routes/persistent-chat.tsx` around lines 20 - 23,
Update the localStorage persistence codec in
examples/ts-react-chat/src/routes/persistent-chat.tsx (lines 20-23) to revive
persisted message date fields as Date instances, while preserving JSON
serialization. Apply the same codec in
testing/e2e/src/routes/persistence-durability.tsx (lines 25-28) and add an
assertion that the rehydrated timestamp is a Date.
packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs-1-2 (1)

1-2: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wrap the CLI entrypoint in a try/catch. packages/ai-persistence-cloudflare/src/cli.ts awaits runCloudflareMigrationsCli(...) directly, so MigrationCliError rejections will surface as raw stack traces. Print the message to stderr and set process.exitCode = 1 instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs`
around lines 1 - 2, Wrap the CLI import and execution in the migration
entrypoint around runCloudflareMigrationsCli with try/catch handling for
MigrationCliError rejections; print the error message to stderr and set
process.exitCode to 1 instead of allowing a raw stack trace. Preserve normal
successful CLI execution.
packages/ai-persistence-prisma/package.json-81-82 (1)

81-82: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use workspace:* for internal peer dependencies.

Replace workspace:^ with the required workspace:* protocol.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence-prisma/package.json` around lines 81 - 82, Update the
internal dependencies "`@tanstack/ai`" and "`@tanstack/ai-persistence`" in
package.json to use the workspace:* protocol instead of workspace:^.

Source: Coding guidelines

packages/ai-persistence-drizzle/tests/package-contract.test.ts-42-49 (1)

42-49: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Detect dynamic Node and SQLite imports too.

await import('node:sqlite') or await import('./sqlite') passes these from-only checks while making the root entry unsafe for edge runtimes.

Suggested update
-      expect(contents, filename).not.toMatch(/from ['"]node:/)
+      expect(contents, filename).not.toMatch(
+        /(?:from\s*|import\s*\()\s*['"]node:/,
+      )
...
-    expect(root).not.toMatch(/from ['"].*sqlite/)
+    expect(root).not.toMatch(
+      /(?:from\s*|import\s*\()\s*['"][^'"]*sqlite/,
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence-drizzle/tests/package-contract.test.ts` around lines
42 - 49, Extend the import checks in the package contract test to reject dynamic
import syntax as well as static from imports. Update the assertions covering
package files and the root index loaded via fileURLToPath so await import
references to node: modules, Buffer usage, and SQLite paths such as node:sqlite
or relative sqlite imports are detected.
packages/ai/skills/ai-core/chat-experience/SKILL.md-506-508 (1)

506-508: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Garbled sentence — fix the parenthetical.

"(the one exception to mistake j below)" doesn't parse; likely a leftover editing artifact.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/skills/ai-core/chat-experience/SKILL.md` around lines 506 - 508,
Fix the parenthetical sentence in the storage adapter and ChatPersistedState
guidance by removing the garbled “one exception to mistake j below” wording and
replacing it with a clear, grammatically correct statement consistent with the
intended import guidance. Keep the surrounding distinction between
`@tanstack/ai-client` imports and framework-package useChat imports unchanged.
packages/ai-persistence/package.json-49-51 (1)

49-51: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the required workspace protocol.

Change @tanstack/ai from workspace:^ to workspace:*.

As per coding guidelines, “Use the workspace:* protocol for internal package dependencies in package.json.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence/package.json` around lines 49 - 51, Update the
`@tanstack/ai` entry in the peerDependencies object of package.json from the
workspace:^ protocol to workspace:* while leaving the vitest dependency
unchanged.

Source: Coding guidelines

docs/persistence/browser-refresh.md-45-47 (1)

45-47: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not promise restoration before first paint for async storage.

indexedDBPersistence is documented as async on Lines 55-63, so its record cannot be guaranteed before the initial paint. Describe restoration as completing after hydration/storage loading and recommend a loading state where needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/persistence/browser-refresh.md` around lines 45 - 47, Update the
persistence documentation around the “next load” description to avoid promising
transcript or interrupt restoration before first paint when using async storage
such as indexedDBPersistence. State that restoration completes after
hydration/storage loading, and recommend showing a loading state when the UI
must wait for restored data.
packages/ai-client/tests/resume-snapshot.test.ts-1-16 (1)

1-16: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Colocate these unit tests with their source modules.

Split this new cross-module suite into tests beside src/client-persistor.ts, src/connection-adapters.ts, and src/chat-client.ts.

As per coding guidelines, “Place unit tests in *.test.ts files alongside the source they cover.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-client/tests/resume-snapshot.test.ts` around lines 1 - 16, Split
the cross-module tests in resume-snapshot.test.ts into colocated *.test.ts files
beside client-persistor.ts, connection-adapters.ts, and chat-client.ts, placing
each test with the source module it covers. Preserve the existing assertions and
shared test setup while removing the standalone cross-module suite.

Source: Coding guidelines

docs/persistence/browser-refresh.md-69-77 (1)

69-77: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the server-side resumable endpoint example.

This section requires a server route that records and replays the stream, but only shows client consumption. Include the replay endpoint alongside the useChat example.

As per coding guidelines, “When a documentation page covers both server and client behavior, include snippets for both halves: the server endpoint and the client consumption.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/persistence/browser-refresh.md` around lines 69 - 77, Update the “Rejoin
an in-flight run” section to include a server-side resumable endpoint example
alongside the existing useChat/joinRun client explanation. Show the route’s
stream recording and GET replay behavior, reusing the documented resumable
connection pattern from “Resumable streams,” while preserving the existing
client flow.

Source: Coding guidelines

docs/persistence/internals.md-31-35 (1)

31-35: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the documented onConfig ordering.

Pending interrupts are loaded and resume input is validated before createOrResumeRun. As written, the page incorrectly implies invalid resumes create a run record first.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/persistence/internals.md` around lines 31 - 35, Update the onConfig
description in the persistence internals documentation to state that pending
interrupts are loaded and the request’s resume batch is validated before
createOrResumeRun, then describe run creation or resumption and stored-message
merging in the correct order.
🧹 Nitpick comments (6)
packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql (1)

1-32: 🚀 Performance & Scalability | 🔵 Trivial

Consider secondary indexes for thread/run lookups.

Interrupts and runs are keyed only by their primary IDs, but list-style access (e.g. interrupts for a run/thread, runs for a thread) filters on interrupts.run_id, interrupts.thread_id, and runs.thread_id. Without indexes these become full table scans as rows accumulate. Since this file must stay byte-for-byte identical to the Drizzle asset, add the indexes in the Drizzle schema source and regenerate both assets rather than editing here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql`
around lines 1 - 32, Add secondary indexes for interrupts.run_id,
interrupts.thread_id, and runs.thread_id in the corresponding Drizzle schema
definitions, then regenerate the migration assets so this SQL file remains
byte-for-byte identical to generated output.
packages/ai-persistence-cloudflare/tsconfig.json (1)

1-9: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Global "node" types leak into src, past the Workers-compatibility guard.

types: ["node", ...] applies across include: ["src", "tests"], so Node's ambient globals (process, __dirname, etc.) typecheck fine inside src even though this package targets Cloudflare Workers. The sibling package-contract.test.ts only regex-checks for from 'node:' imports and the literal Buffer, so use of other Node globals in src wouldn't be caught by that guard and would only fail at runtime on Workers.

Consider scoping "node" types to a tests-only tsconfig (or project reference) so src typechecking reflects the actual Workers runtime.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence-cloudflare/tsconfig.json` around lines 1 - 9, Remove
the global "node" type declaration from the package tsconfig's shared
compilerOptions so src is checked only against Cloudflare Workers types. Add a
tests-only tsconfig or project reference that supplies "node" types for tests,
while preserving the existing source and test includes and Workers-compatible
source typechecking.
packages/ai-persistence-drizzle/src/sqlite.ts (1)

32-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the native drizzle-orm/node-sqlite driver here
drizzle-orm/node-sqlite already accepts DatabaseSync directly, so this custom sqlite-proxy callback and row-to-array conversion can be removed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence-drizzle/src/sqlite.ts` around lines 32 - 44, Replace
the custom drizzle sqlite-proxy callback around the database initialization with
the native drizzle-orm/node-sqlite driver, passing the existing DatabaseSync
instance directly to drizzle. Remove the statement preparation, method
branching, and Object.values row conversion while preserving the resulting
database handle used by the rest of the module.
packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma (1)

15-40: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add indexes for threadId/runId on Interrupt.

stores.ts queries Interrupt via findMany({ where: { threadId } }) and findMany({ where: { runId } }) (list/listPending/listByRun/listPendingByRun), but no @@index is declared for either column here. As interrupt volume grows this forces full table scans on every listing call.

⚡ Proposed indexes
 model Interrupt {
   interruptId  String  `@id` `@map`("interrupt_id")
   runId        String  `@map`("run_id")
   threadId     String  `@map`("thread_id")
   status       String
   requestedAt  BigInt  `@map`("requested_at")
   resolvedAt   BigInt? `@map`("resolved_at")
   payloadJson  String  `@map`("payload_json")
   responseJson String? `@map`("response_json")

+  @@index([threadId])
+  @@index([runId])
   @@map("interrupts")
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma` around lines 15
- 40, Add separate Prisma indexes for both threadId and runId in the Interrupt
model, alongside its existing fields and mapping, so the
list/listPending/listByRun/listPendingByRun queries can use indexed lookups.
packages/ai-persistence/tests/memory.conformance.test.ts (1)

1-4: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Colocate the memory-store unit tests with their covered source.

  • packages/ai-persistence/tests/memory.conformance.test.ts#L1-L4: move beside packages/ai-persistence/src/memory.ts.
  • packages/ai-persistence/tests/memory.test.ts#L1-L132: move beside packages/ai-persistence/src/memory.ts.

As per coding guidelines, “Place unit tests in *.test.ts files alongside the source they cover.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence/tests/memory.conformance.test.ts` around lines 1 - 4,
Move the tests covering memoryPersistence from
packages/ai-persistence/tests/memory.conformance.test.ts (lines 1-4) and
packages/ai-persistence/tests/memory.test.ts (lines 1-132) to *.test.ts files
alongside packages/ai-persistence/src/memory.ts, preserving their existing test
behavior and updating relative imports as needed.

Source: Coding guidelines

packages/ai-persistence/src/middleware.ts (1)

333-338: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer EventType.RUN_FINISHED here. Using the enum keeps this guard aligned with the shared event type and avoids a silent mismatch if the event name changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence/src/middleware.ts` around lines 333 - 338, Update the
guard in the RUN_FINISHED handling flow to compare chunk.type against the shared
EventType.RUN_FINISHED enum member instead of the string literal, while
preserving the existing interrupt outcome check and early return behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/ts-react-chat/src/routes/api.persistent-chat.ts`:
- Around line 51-53: Scope persisted chat threads to the authenticated tenant:
in examples/ts-react-chat/src/routes/api.persistent-chat.ts#L51-L53, derive and
authorize the persistence thread identity server-side before invoking
withChatPersistence instead of trusting caller-controlled params.threadId; in
examples/ts-react-chat/src/routes/persistent-chat.tsx#L28-L32, replace the
globally shared persistent ID demonstration with an authenticated/session-scoped
thread ID.

In `@packages/ai-client/src/chat-client.ts`:
- Around line 445-466: Update the chat client initialization and hydrateAsync
flow so asynchronously loaded persistence also extracts a bare in-flight run’s
resumeState.runId and passes it to resumeInFlightRun() after the processor is
ready. Preserve pending-interrupt handling through applyResumeSnapshot(), while
ensuring the synchronous and asynchronous paths use consistent rejoin behavior.

In `@packages/ai-client/src/connection-adapters.ts`:
- Around line 875-885: Update the normalized adapter’s conditional spread around
joinRun to expose the wrapper only when typeof connection.joinRun is "function",
rather than merely checking property presence. Preserve the existing
ResumableConnectConnectionAdapter delegation and ensure an explicit undefined
joinRun is omitted.

In `@packages/ai-persistence-drizzle/package.json`:
- Around line 55-56: Update the internal dependency declarations in package.json
for `@tanstack/ai` and `@tanstack/ai-persistence` from workspace:^ to workspace:*.
Leave external dependencies unchanged.

In `@packages/ai-persistence-drizzle/src/sqlite.ts`:
- Around line 20-30: Add an engines.node declaration to
packages/ai-persistence-drizzle/package.json requiring Node 22.13.0 or newer, or
the equivalent supported Node release range that excludes versions where
node:sqlite requires the experimental flag. Keep the sqlitePersistence
implementation unchanged.

In `@packages/ai-persistence-drizzle/tests/custom-schema.test.ts`:
- Around line 1-113: Move the tests beside the source modules they cover,
preserving their contents and behavior: relocate
packages/ai-persistence-drizzle/tests/custom-schema.test.ts (lines 1-113) beside
the Drizzle persistence/schema sources,
packages/ai-persistence-drizzle/tests/package-contract.test.ts (lines 1-67)
beside the Drizzle package-contract surface, and
packages/ai-persistence-prisma/tests/package-contract.test.ts (lines 1-28)
beside the Prisma package-contract surface.

In `@packages/ai-persistence-drizzle/tests/migration-cli.test.ts`:
- Around line 1-6: Move the migration CLI tests from
packages/ai-persistence-drizzle/tests/migration-cli.test.ts to
packages/ai-persistence-drizzle/src/migration-cli.test.ts alongside
runDrizzleMigrationsCli. Update packages/ai-persistence-drizzle/vite.config.ts
at line 13 to discover colocated tests such as src/**/*.test.ts, preserving test
execution after the move.

In `@packages/ai-persistence-drizzle/tests/schema-source.test.ts`:
- Around line 1-31: Move the schema comparison test from the package tests
directory to a *.test.ts file alongside src/schema.ts, preserving normalize and
the emitted-schema structural assertions. Update the package’s test include
configuration only if needed so the colocated test is discovered.

In `@packages/ai-persistence-prisma/src/index.ts`:
- Around line 44-48: Update the prismaPersistence parameter to accept the
minimal delegate-surface type required by resolveDelegates instead of this
package’s generated PrismaClient type. Ensure consumer-generated Prisma clients,
including clients with renamed models, can be passed directly without casts,
while preserving the existing options?.models delegation flow.

In `@packages/ai-persistence-prisma/vite.config.ts`:
- Line 13: Update the test include configuration in vite.config.ts to discover
colocated *.test.ts files throughout the source tree, not only
tests/**/*.test.ts. Move existing package tests alongside the modules they cover
while preserving their test names and coverage.

In `@packages/ai-persistence/src/locks.ts`:
- Around line 46-57: Update the lock-chain cleanup around the chains map so a
key is removed once its current run settles, while preserving newer work that
may have been queued for the same key. Use the existing key and chain identity
to ensure an older completion cannot delete a replacement chain, and keep
rejection swallowing behavior unchanged.

In `@packages/ai-persistence/src/memory.ts`:
- Around line 98-127: Update the interrupt query methods list, listPending,
listByRun, and listPendingByRun to sort their filtered results by ascending
requestedAt before returning them. Preserve each method’s existing threadId,
runId, and pending-status filters while ensuring all query results follow the
InterruptStore ordering contract rather than Map insertion order.
- Around line 131-143: Replace the flat string-key storage in the metadata
methods get, set, and delete with nested maps or another collision-free
representation that preserves scope and key as separate components. Ensure
distinct pairs such as scope “a:b” with key “c” and scope “a” with key “b:c”
remain independently retrievable, writable, and deletable.

In `@testing/e2e/tests/persistence-durability.spec.ts`:
- Around line 17-21: Extend the persistence durability E2E fixture to pause
deterministically mid-stream, reload during the active run, and verify useChat
issues a resume/joinRun request and renders exactly one continuation. During the
interrupt reload, block API traffic to prove recovery uses local storage only,
then restore traffic for the resumed request; update the existing excluded-path
note and assertions accordingly.

---

Outside diff comments:
In `@packages/ai-client/src/chat-client.ts`:
- Line 1: Update the async hydration flow around readInitial() and
hydrateAsync() to carry the computed rejoinRunId through promise-backed
persistence, then invoke resumeInFlightRun() once the processor is ready.
Preserve the existing synchronous rejoin behavior and ensure the callback runs
only after the resume snapshot has been reapplied.

In `@packages/ai-persistence-cloudflare/tests/migration-cli.test.ts`:
- Around line 1-66: Move the migration CLI test suite from the tests directory
to a *.test.ts file alongside migration-cli.ts under src, preserving its
existing imports, test cases, and behavior.

---

Minor comments:
In `@docs/persistence/browser-refresh.md`:
- Around line 45-47: Update the persistence documentation around the “next load”
description to avoid promising transcript or interrupt restoration before first
paint when using async storage such as indexedDBPersistence. State that
restoration completes after hydration/storage loading, and recommend showing a
loading state when the UI must wait for restored data.
- Around line 69-77: Update the “Rejoin an in-flight run” section to include a
server-side resumable endpoint example alongside the existing useChat/joinRun
client explanation. Show the route’s stream recording and GET replay behavior,
reusing the documented resumable connection pattern from “Resumable streams,”
while preserving the existing client flow.

In `@docs/persistence/internals.md`:
- Around line 31-35: Update the onConfig description in the persistence
internals documentation to state that pending interrupts are loaded and the
request’s resume batch is validated before createOrResumeRun, then describe run
creation or resumption and stored-message merging in the correct order.

In `@examples/ts-react-chat/src/routes/persistent-chat.tsx`:
- Around line 20-23: Update the localStorage persistence codec in
examples/ts-react-chat/src/routes/persistent-chat.tsx (lines 20-23) to revive
persisted message date fields as Date instances, while preserving JSON
serialization. Apply the same codec in
testing/e2e/src/routes/persistence-durability.tsx (lines 25-28) and add an
assertion that the rehydrated timestamp is a Date.

In `@packages/ai-client/tests/resume-snapshot.test.ts`:
- Around line 1-16: Split the cross-module tests in resume-snapshot.test.ts into
colocated *.test.ts files beside client-persistor.ts, connection-adapters.ts,
and chat-client.ts, placing each test with the source module it covers. Preserve
the existing assertions and shared test setup while removing the standalone
cross-module suite.

In
`@packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs`:
- Around line 1-2: Wrap the CLI import and execution in the migration entrypoint
around runCloudflareMigrationsCli with try/catch handling for MigrationCliError
rejections; print the error message to stderr and set process.exitCode to 1
instead of allowing a raw stack trace. Preserve normal successful CLI execution.

In `@packages/ai-persistence-drizzle/tests/package-contract.test.ts`:
- Around line 42-49: Extend the import checks in the package contract test to
reject dynamic import syntax as well as static from imports. Update the
assertions covering package files and the root index loaded via fileURLToPath so
await import references to node: modules, Buffer usage, and SQLite paths such as
node:sqlite or relative sqlite imports are detected.

In `@packages/ai-persistence-prisma/package.json`:
- Around line 81-82: Update the internal dependencies "`@tanstack/ai`" and
"`@tanstack/ai-persistence`" in package.json to use the workspace:* protocol
instead of workspace:^.

In `@packages/ai-persistence/package.json`:
- Around line 49-51: Update the `@tanstack/ai` entry in the peerDependencies
object of package.json from the workspace:^ protocol to workspace:* while
leaving the vitest dependency unchanged.

In `@packages/ai/skills/ai-core/chat-experience/SKILL.md`:
- Around line 506-508: Fix the parenthetical sentence in the storage adapter and
ChatPersistedState guidance by removing the garbled “one exception to mistake j
below” wording and replacing it with a clear, grammatically correct statement
consistent with the intended import guidance. Keep the surrounding distinction
between `@tanstack/ai-client` imports and framework-package useChat imports
unchanged.

---

Nitpick comments:
In `@packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql`:
- Around line 1-32: Add secondary indexes for interrupts.run_id,
interrupts.thread_id, and runs.thread_id in the corresponding Drizzle schema
definitions, then regenerate the migration assets so this SQL file remains
byte-for-byte identical to generated output.

In `@packages/ai-persistence-cloudflare/tsconfig.json`:
- Around line 1-9: Remove the global "node" type declaration from the package
tsconfig's shared compilerOptions so src is checked only against Cloudflare
Workers types. Add a tests-only tsconfig or project reference that supplies
"node" types for tests, while preserving the existing source and test includes
and Workers-compatible source typechecking.

In `@packages/ai-persistence-drizzle/src/sqlite.ts`:
- Around line 32-44: Replace the custom drizzle sqlite-proxy callback around the
database initialization with the native drizzle-orm/node-sqlite driver, passing
the existing DatabaseSync instance directly to drizzle. Remove the statement
preparation, method branching, and Object.values row conversion while preserving
the resulting database handle used by the rest of the module.

In `@packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma`:
- Around line 15-40: Add separate Prisma indexes for both threadId and runId in
the Interrupt model, alongside its existing fields and mapping, so the
list/listPending/listByRun/listPendingByRun queries can use indexed lookups.

In `@packages/ai-persistence/src/middleware.ts`:
- Around line 333-338: Update the guard in the RUN_FINISHED handling flow to
compare chunk.type against the shared EventType.RUN_FINISHED enum member instead
of the string literal, while preserving the existing interrupt outcome check and
early return behavior.

In `@packages/ai-persistence/tests/memory.conformance.test.ts`:
- Around line 1-4: Move the tests covering memoryPersistence from
packages/ai-persistence/tests/memory.conformance.test.ts (lines 1-4) and
packages/ai-persistence/tests/memory.test.ts (lines 1-132) to *.test.ts files
alongside packages/ai-persistence/src/memory.ts, preserving their existing test
behavior and updating relative imports as needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e3a12367-1bdc-4ac2-b7bf-8d3f28b91b6d

📥 Commits

Reviewing files that changed from the base of the PR and between 50d7a7d and b443a9f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (134)
  • .changeset/client-browser-refresh-durability.md
  • .changeset/persistence-packages.md
  • docs/chat/persistence.md
  • docs/config.json
  • docs/persistence/browser-refresh.md
  • docs/persistence/chat-persistence.md
  • docs/persistence/cloudflare.md
  • docs/persistence/controls.md
  • docs/persistence/custom-stores.md
  • docs/persistence/drizzle.md
  • docs/persistence/internals.md
  • docs/persistence/migrations.md
  • docs/persistence/overview.md
  • docs/persistence/prisma.md
  • docs/persistence/sql-backends.md
  • examples/ts-react-chat/.gitignore
  • examples/ts-react-chat/README.md
  • examples/ts-react-chat/package.json
  • examples/ts-react-chat/src/components/Header.tsx
  • examples/ts-react-chat/src/routes/api.persistent-chat.ts
  • examples/ts-react-chat/src/routes/persistent-chat.tsx
  • packages/ai-client/src/chat-client.ts
  • packages/ai-client/src/client-persistor.ts
  • packages/ai-client/src/connection-adapters.ts
  • packages/ai-client/src/index.ts
  • packages/ai-client/src/storage-adapters.ts
  • packages/ai-client/src/types.ts
  • packages/ai-client/tests/resume-snapshot.test.ts
  • packages/ai-persistence-cloudflare/bin/tanstack-ai-cloudflare-migrations.mjs
  • packages/ai-persistence-cloudflare/migrations/0000_tanstack_ai_initial.sql
  • packages/ai-persistence-cloudflare/package.json
  • packages/ai-persistence-cloudflare/src/assets.d.ts
  • packages/ai-persistence-cloudflare/src/assets/0000_tanstack_ai_initial.sql
  • packages/ai-persistence-cloudflare/src/bindings.ts
  • packages/ai-persistence-cloudflare/src/cli.ts
  • packages/ai-persistence-cloudflare/src/d1.ts
  • packages/ai-persistence-cloudflare/src/index.ts
  • packages/ai-persistence-cloudflare/src/locks.ts
  • packages/ai-persistence-cloudflare/src/migration-cli.ts
  • packages/ai-persistence-cloudflare/src/migrations.ts
  • packages/ai-persistence-cloudflare/tests/api-types.test-d.ts
  • packages/ai-persistence-cloudflare/tests/locks.test.ts
  • packages/ai-persistence-cloudflare/tests/migration-cli.test.ts
  • packages/ai-persistence-cloudflare/tests/migrations.test.ts
  • packages/ai-persistence-cloudflare/tests/package-contract.test.ts
  • packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts
  • packages/ai-persistence-cloudflare/tsconfig.json
  • packages/ai-persistence-cloudflare/vite.config.ts
  • packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-migrations.mjs
  • packages/ai-persistence-drizzle/bin/tanstack-ai-drizzle-schema.mjs
  • packages/ai-persistence-drizzle/drizzle.config.ts
  • packages/ai-persistence-drizzle/drizzle/0000_tanstack_ai_initial.sql
  • packages/ai-persistence-drizzle/drizzle/meta/0000_snapshot.json
  • packages/ai-persistence-drizzle/drizzle/meta/_journal.json
  • packages/ai-persistence-drizzle/package.json
  • packages/ai-persistence-drizzle/src/assets.d.ts
  • packages/ai-persistence-drizzle/src/assets/0000_tanstack_ai_initial.sql
  • packages/ai-persistence-drizzle/src/assets/tanstack-ai-schema.ts
  • packages/ai-persistence-drizzle/src/cli.ts
  • packages/ai-persistence-drizzle/src/index.ts
  • packages/ai-persistence-drizzle/src/migration-cli.ts
  • packages/ai-persistence-drizzle/src/migrations.ts
  • packages/ai-persistence-drizzle/src/schema-cli-main.ts
  • packages/ai-persistence-drizzle/src/schema-cli.ts
  • packages/ai-persistence-drizzle/src/schema-contract.ts
  • packages/ai-persistence-drizzle/src/schema-source.ts
  • packages/ai-persistence-drizzle/src/schema.ts
  • packages/ai-persistence-drizzle/src/sqlite-migrations.ts
  • packages/ai-persistence-drizzle/src/sqlite.ts
  • packages/ai-persistence-drizzle/src/stores.ts
  • packages/ai-persistence-drizzle/tests/api-types.test-d.ts
  • packages/ai-persistence-drizzle/tests/custom-schema.test.ts
  • packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts
  • packages/ai-persistence-drizzle/tests/migration-cli.test.ts
  • packages/ai-persistence-drizzle/tests/migrations.test.ts
  • packages/ai-persistence-drizzle/tests/package-contract.test.ts
  • packages/ai-persistence-drizzle/tests/schema-cli.test.ts
  • packages/ai-persistence-drizzle/tests/schema-source.test.ts
  • packages/ai-persistence-drizzle/tests/sqlite.test.ts
  • packages/ai-persistence-drizzle/tests/store-behavior.test.ts
  • packages/ai-persistence-drizzle/tests/variant-schema.ts
  • packages/ai-persistence-drizzle/tsconfig.json
  • packages/ai-persistence-drizzle/vite.config.ts
  • packages/ai-persistence-prisma/.gitignore
  • packages/ai-persistence-prisma/bin/tanstack-ai-prisma-models.mjs
  • packages/ai-persistence-prisma/package.json
  • packages/ai-persistence-prisma/prisma/schema.prisma
  • packages/ai-persistence-prisma/prisma/tanstack-ai.prisma
  • packages/ai-persistence-prisma/src/assets.d.ts
  • packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma
  • packages/ai-persistence-prisma/src/cli.ts
  • packages/ai-persistence-prisma/src/index.ts
  • packages/ai-persistence-prisma/src/model-contract.ts
  • packages/ai-persistence-prisma/src/models-cli.ts
  • packages/ai-persistence-prisma/src/models.ts
  • packages/ai-persistence-prisma/src/stores.ts
  • packages/ai-persistence-prisma/tests/api-types.test-d.ts
  • packages/ai-persistence-prisma/tests/model-mapping.test.ts
  • packages/ai-persistence-prisma/tests/models-cli.test.ts
  • packages/ai-persistence-prisma/tests/models.test.ts
  • packages/ai-persistence-prisma/tests/package-contract.test.ts
  • packages/ai-persistence-prisma/tests/prisma.conformance.test.ts
  • packages/ai-persistence-prisma/tests/store-behavior.test.ts
  • packages/ai-persistence-prisma/tsconfig.json
  • packages/ai-persistence-prisma/vite.config.ts
  • packages/ai-persistence/package.json
  • packages/ai-persistence/src/capabilities.ts
  • packages/ai-persistence/src/index.ts
  • packages/ai-persistence/src/interrupts.ts
  • packages/ai-persistence/src/locks.ts
  • packages/ai-persistence/src/memory.ts
  • packages/ai-persistence/src/middleware.ts
  • packages/ai-persistence/src/testkit/conformance.ts
  • packages/ai-persistence/src/types.ts
  • packages/ai-persistence/tests/capabilities.test.ts
  • packages/ai-persistence/tests/error-abort.test.ts
  • packages/ai-persistence/tests/interrupts.test.ts
  • packages/ai-persistence/tests/memory.conformance.test.ts
  • packages/ai-persistence/tests/memory.test.ts
  • packages/ai-persistence/tests/persistence-composition.test.ts
  • packages/ai-persistence/tests/persistence-fixtures.ts
  • packages/ai-persistence/tests/persistence-types.test-d.ts
  • packages/ai-persistence/tests/persistence-validation.test.ts
  • packages/ai-persistence/tests/state-only.test.ts
  • packages/ai-persistence/tests/with-persistence.test.ts
  • packages/ai-persistence/tsconfig.json
  • packages/ai-persistence/vite.config.ts
  • packages/ai/skills/ai-core/chat-experience/SKILL.md
  • packages/ai/skills/ai-core/middleware/SKILL.md
  • pnpm-workspace.yaml
  • testing/e2e/src/routeTree.gen.ts
  • testing/e2e/src/routes/api.persistence-durability.ts
  • testing/e2e/src/routes/persistence-durability.tsx
  • testing/e2e/tests/persistence-durability.spec.ts

Comment thread examples/ts-react-chat/src/routes/api.persistent-chat.ts Outdated
Comment thread packages/ai-client/src/chat-client.ts
Comment thread packages/ai-client/src/connection-adapters.ts Outdated
Comment thread packages/ai-persistence-drizzle/package.json Outdated
Comment thread packages/ai-persistence-drizzle/src/sqlite.ts Outdated
Comment thread packages/ai-persistence-prisma/vite.config.ts
Comment thread packages/ai-persistence/src/locks.ts Outdated
Comment thread packages/ai-persistence/src/memory.ts
Comment thread packages/ai-persistence/src/memory.ts Outdated
Comment on lines +17 to +21
* The mid-stream "rejoin an in-flight run via joinRun after reload" path is NOT
* covered here: the harness stream completes in a single tick, so there is no
* deterministic window to reload while a run is still producing. That resume
* cursor is covered at the transport layer by `delivery-durability.spec.ts` and
* in `@tanstack/ai-client` unit tests.

@coderabbitai coderabbitai Bot Jul 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Add E2E coverage for useChat rejoining an in-flight run.

This explicitly excludes the new reload-and-joinRun behavior. Make the fixture pause mid-stream, reload, assert the client issues a resume request, and verify exactly one continuation is rendered. Also block API traffic during the interrupt reload to prove that case is local-storage-only.

As per coding guidelines, “Every feature, bug fix, or behavior change must include E2E test coverage in testing/e2e/.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@testing/e2e/tests/persistence-durability.spec.ts` around lines 17 - 21,
Extend the persistence durability E2E fixture to pause deterministically
mid-stream, reload during the active run, and verify useChat issues a
resume/joinRun request and renders exactly one continuation. During the
interrupt reload, block API traffic to prove recovery uses local storage only,
then restore traffic for the resumed request; update the existing excluded-path
note and assertions accordingly.

Source: Coding guidelines

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — mid-stream reload/joinRun is intentionally out of this E2E file (see the header comment in persistence-durability.spec.ts).

What is covered today:

  • Unit (@tanstack/ai-client): combined { messages, resume } record, async hydrate rejoin, resumeInFlightRun / applyResumeSnapshot, and the explicit-undefined joinRun guard on the connection adapter.
  • E2E: transcript restore after clean finish, and interrupt survival across reload (local resume snapshot + server-authoritative reconstruction).

A deterministic mid-stream pause + reload harness needs a fixture that freezes the stream mid-token, reloads, asserts the client issues the resume/join request, and checks single continuation — that is real work (aimock fixture + transport coordination) and is tracked as follow-up rather than blocking this PR. The durable-stream e2e (e87998db8) already guards full agent-loop delivery through durability-backed streams; the missing piece is specifically the browser reload while streaming path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- current persistence durability spec header and test names ---'
sed -n '1,140p' testing/e2e/tests/persistence-durability.spec.ts

echo '--- client unit-test references to in-flight rejoin ---'
rg -n -C 4 'resumeInFlightRun|joinRun|in-flight|rejoin' packages/ai-client/tests packages/ai-client/src

echo '--- referenced commit metadata and changed paths (if available) ---'
git show --no-patch --format='%H%n%s%n%b' e87998db8 2>&1 || true
git diff-tree --no-commit-id --name-status -r e87998db8 2>&1 || true

Length of output: 99489


@tombeckenham, verified. The unit suite covers persisted and async rejoin paths, including duplicate-message handling, and e87998db8 covers durable agent-loop delivery plus a second-tab join.

The missing coverage is specifically the composed browser scenario—page.reload() during an active run—which is not exercised by either suite. Given the focused coverage already present and the fixture work required, I’m okay treating it as non-blocking follow-up rather than requiring it in this PR.

Would you like me to open a GitHub issue for the deterministic mid-stream reload/rejoin E2E scenario, with the acceptance criteria from the original finding?


🧠 Learnings used
Learnt from: season179
Repo: TanStack/ai PR: 734
File: testing/e2e/tests/workerd-console-logger.spec.ts:57-100
Timestamp: 2026-06-10T00:45:26.781Z
Learning: In `testing/e2e/**/*.spec.ts`, apply the “Playwright + aimock” policy for E2E features/fixes that exercise LLM provider HTTP calls: wire aimock to mock LLM provider responses instead of hitting the provider over the network. Exception: if the E2E spec only tests runtime/infrastructure behavior (e.g., rendering/logging inside real `workerd` via Miniflare) and the code path under test never reaches the provider HTTP layer, do not add aimock wiring. For these exempt specs, document the policy exception in the spec’s header comment (i.e., explain why the test does not reach the LLM provider HTTP layer).

@tombeckenham tombeckenham added the waiting-on: author Waiting for the author to respond or update label Jul 23, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/ai/skills/ai-core/chat-experience/SKILL.md (2)

507-508: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the malformed import guidance sentence.

“the one exception to mistake j below” is unclear and appears to contain an accidental edit. Replace it with the intended wording before publishing the skill.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/skills/ai-core/chat-experience/SKILL.md` around lines 507 - 508,
Correct the import guidance sentence in the skill documentation so it clearly
states that framework packages should be imported from `@tanstack/ai-client`, with
useChat remaining the exception sourced from the framework package. Remove the
malformed “mistake j below” wording without changing the surrounding guidance.

517-520: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use a Date-aware deserializer.

UIMessage.createdAt is a Date, but this example uses JSON.stringify / JSON.parse, so the timestamp is restored as a string on reload and does not preserve the documented ChatPersistedState shape. Use the repository’s Date-aware codec or a deserializer that reconstructs Date fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/skills/ai-core/chat-experience/SKILL.md` around lines 517 - 520,
Update the localStoragePersistence configuration around ChatPersistedState to
use the repository’s Date-aware serialization/deserialization codec, or
reconstruct Date fields during deserialize, so UIMessage.createdAt is restored
as a Date while preserving the existing persisted state shape.
docs/persistence/browser-refresh.md (1)

28-30: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the localStorage codec Date-aware.

UIMessage.createdAt is typed as Date, so JSON.stringify stores it as a string and JSON.parse returns that string instead of a Date; the useChat transcript is then restored with mutated timestamps. Use a Date-aware validated codec here, or switch this sample to indexedDBPersistence since it already supports structured cloning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/persistence/browser-refresh.md` around lines 28 - 30, Update the
localStoragePersistence codec for ChatPersistedState so UIMessage.createdAt
values are restored as Date instances rather than strings; use an existing
Date-aware validated codec, or replace localStoragePersistence with
indexedDBPersistence to preserve structured-cloned dates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/persistence/browser-refresh.md`:
- Around line 99-102: Expand the server-authoritative reload example around the
router-loader discussion to include a concrete server endpoint that derives and
authorizes the thread ID, calls
persistence.stores.messages.loadThread(threadId), and returns the messages. Add
the corresponding client-side consumption showing how the endpoint response
seeds initialMessages, while preserving the distinction from the delivery replay
endpoint.

In `@examples/ts-react-chat/src/routes/api.persistent-chat.ts`:
- Around line 78-80: Update the history-loading branch around loadThread so it
does not trust the caller-controlled threadId query parameter. Derive the thread
identity from the authenticated session and authorize it for the current tenant
before calling persistence.stores.messages.loadThread, preserving the existing
JSON response only for authorized sessions.

---

Outside diff comments:
In `@docs/persistence/browser-refresh.md`:
- Around line 28-30: Update the localStoragePersistence codec for
ChatPersistedState so UIMessage.createdAt values are restored as Date instances
rather than strings; use an existing Date-aware validated codec, or replace
localStoragePersistence with indexedDBPersistence to preserve structured-cloned
dates.

In `@packages/ai/skills/ai-core/chat-experience/SKILL.md`:
- Around line 507-508: Correct the import guidance sentence in the skill
documentation so it clearly states that framework packages should be imported
from `@tanstack/ai-client`, with useChat remaining the exception sourced from the
framework package. Remove the malformed “mistake j below” wording without
changing the surrounding guidance.
- Around line 517-520: Update the localStoragePersistence configuration around
ChatPersistedState to use the repository’s Date-aware
serialization/deserialization codec, or reconstruct Date fields during
deserialize, so UIMessage.createdAt is restored as a Date while preserving the
existing persisted state shape.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 177c8b89-cf85-45bd-9516-9a7d3ad5c65e

📥 Commits

Reviewing files that changed from the base of the PR and between b443a9f and 89932a8.

📒 Files selected for processing (13)
  • .changeset/client-browser-refresh-durability.md
  • docs/chat/persistence.md
  • docs/config.json
  • docs/persistence/browser-refresh.md
  • docs/persistence/controls.md
  • examples/ts-react-chat/src/routes/api.persistent-chat.ts
  • examples/ts-react-chat/src/routes/persistent-chat.tsx
  • packages/ai-client/src/chat-client.ts
  • packages/ai-client/src/client-persistor.ts
  • packages/ai-client/src/index.ts
  • packages/ai-client/src/types.ts
  • packages/ai-client/tests/resume-snapshot.test.ts
  • packages/ai/skills/ai-core/chat-experience/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (10)
  • .changeset/client-browser-refresh-durability.md
  • docs/config.json
  • packages/ai-client/src/index.ts
  • docs/persistence/controls.md
  • packages/ai-client/tests/resume-snapshot.test.ts
  • docs/chat/persistence.md
  • packages/ai-client/src/types.ts
  • examples/ts-react-chat/src/routes/persistent-chat.tsx
  • packages/ai-client/src/chat-client.ts
  • packages/ai-client/src/client-persistor.ts

Comment thread docs/persistence/browser-refresh.md Outdated
Comment thread examples/ts-react-chat/src/routes/api.persistent-chat.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/persistence/overview.md`:
- Line 40: Update the Client row in the persistence options table to mention
sessionStorage alongside localStorage and IndexedDB, preserving the existing
description and formatting.
- Line 32: Update the persistence-layer comparison around the sentence stating
that the layers share “no code.” Replace it with wording that preserves their
separate responsibilities while acknowledging the integration contract between
client-persisted resume/runId metadata and delivery-durable run rejoining.
- Around line 81-84: Update the localStoragePersistence example’s deserialize
callback to validate and narrow the JSON.parse result to ChatPersistedState
before returning it, using the project’s established Standard Schema parser or
type guard rather than trusting the parsed value directly; preserve the existing
serialization behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bab97e0-76be-49ac-b3f5-d16b75985bdd

📥 Commits

Reviewing files that changed from the base of the PR and between 89932a8 and e8aef35.

📒 Files selected for processing (3)
  • docs/config.json
  • docs/persistence/overview.md
  • docs/resumable-streams/overview.md

Comment thread docs/persistence/overview.md
Comment thread docs/persistence/overview.md Outdated
Comment thread docs/persistence/overview.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/ai/skills/ai-core/chat-experience/SKILL.md (1)

495-506: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Use threadId consistently as the persistence key.

This section alternates between chat id, threadId, and stable id. Align the prose and framework example on threadId, and document id only as the explicit storage-key override if that is the intended API; otherwise users may fail to restore records after reload.

Also applies to: 548-551

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/skills/ai-core/chat-experience/SKILL.md` around lines 495 - 506,
Update the persistence documentation and framework example to use threadId
consistently as the stable persistence key. Remove ambiguous references to chat
id or stable id, and document id only if it is the explicit storage-key override
supported by the API; otherwise ensure the example passes threadId so reloads
restore the same record.
packages/ai-client/src/storage-adapters.ts (1)

149-160: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Handle existing IndexedDB databases whose configured object store is missing.

factory.open(databaseName) without a version only raises onupgradeneeded for a new database; an existing database opened at its current version will create no object store in this branch. If objectStoreName was changed later, runRequest() will open a transaction for a non-existent store and start failing. Add a schema/version migration path, or clear/reject before caching the connection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-client/src/storage-adapters.ts` around lines 149 - 160, The
IndexedDB open flow does not migrate existing databases when the configured
object store is missing. Update the database-opening logic around factory.open
and request.onupgradeneeded to detect absent objectStoreName stores and trigger
a versioned schema upgrade that creates the store before caching or using the
connection; otherwise reject and avoid caching an unusable connection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/persistence/overview.md`:
- Around line 216-218: The “A cheap client” bullet incorrectly claims the
browser never parses or stores the transcript. Update that bullet to state that
the browser does not persist the long transcript in browser storage, while
preserving the existing claims about avoiding localStorage quota and
startup-persistence costs.
- Around line 85-87: Update the persistence examples in the affected sections to
derive thread IDs from the authenticated user rather than using the shared
literal support-chat or caller-controlled params.threadId. Ensure both POST and
GET history flows authorize the requested thread ID against the authenticated
user before persisting, reading, or reconstructing conversation history.

In `@packages/ai-persistence/src/reconstruct.ts`:
- Around line 18-35: The reconstructChat function must enforce authorization or
tenant scoping before calling persistence.stores.messages.loadThread(threadId).
Add a mandatory authorization/scoping contract, such as an auth callback or
caller-provided validated thread ID, and ensure unauthorized or out-of-scope
requests return without loading or exposing the transcript.
- Around line 31-35: The reconstructChat hydration response currently returns
stored ModelMessage values instead of the UIMessage contract. Update the
messages flow around persistence.stores.messages.loadThread and the returned
Response to convert loaded model messages with the existing
modelMessagesToUIMessages or equivalent typed converter, and declare the
response as the supported ChatPersistedState<{ messages: Array<UIMessage> }> or
Array<UIMessage> contract while preserving the empty-message behavior.
- Around line 35-37: Update the Response construction in reconstruct.ts to
include a no-store cache-control header alongside the existing content-type
header, ensuring transcript responses are not cached or reused while preserving
the serialized messages payload.

---

Outside diff comments:
In `@packages/ai-client/src/storage-adapters.ts`:
- Around line 149-160: The IndexedDB open flow does not migrate existing
databases when the configured object store is missing. Update the
database-opening logic around factory.open and request.onupgradeneeded to detect
absent objectStoreName stores and trigger a versioned schema upgrade that
creates the store before caching or using the connection; otherwise reject and
avoid caching an unusable connection.

In `@packages/ai/skills/ai-core/chat-experience/SKILL.md`:
- Around line 495-506: Update the persistence documentation and framework
example to use threadId consistently as the stable persistence key. Remove
ambiguous references to chat id or stable id, and document id only if it is the
explicit storage-key override supported by the API; otherwise ensure the example
passes threadId so reloads restore the same record.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f79e751-1747-4eb2-942c-33aacb3c8bcb

📥 Commits

Reviewing files that changed from the base of the PR and between e8aef35 and e5c4064.

📒 Files selected for processing (22)
  • .changeset/client-browser-refresh-durability.md
  • .changeset/persistence-packages.md
  • docs/chat/persistence.md
  • docs/persistence/browser-refresh.md
  • docs/persistence/overview.md
  • examples/ts-react-chat/src/routes/api.persistent-chat.ts
  • examples/ts-react-chat/src/routes/persistent-chat.tsx
  • packages/ai-angular/src/index.ts
  • packages/ai-client/src/chat-client.ts
  • packages/ai-client/src/index.ts
  • packages/ai-client/src/storage-adapters.ts
  • packages/ai-client/src/types.ts
  • packages/ai-client/tests/resume-snapshot.test.ts
  • packages/ai-persistence/src/index.ts
  • packages/ai-persistence/src/reconstruct.ts
  • packages/ai-preact/src/index.ts
  • packages/ai-react/src/index.ts
  • packages/ai-solid/src/index.ts
  • packages/ai-svelte/src/index.ts
  • packages/ai-vue/src/index.ts
  • packages/ai/skills/ai-core/chat-experience/SKILL.md
  • testing/e2e/src/routes/persistence-durability.tsx
💤 Files with no reviewable changes (1)
  • packages/ai-client/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • packages/ai-persistence/src/index.ts
  • .changeset/persistence-packages.md
  • .changeset/client-browser-refresh-durability.md
  • examples/ts-react-chat/src/routes/persistent-chat.tsx
  • packages/ai-client/tests/resume-snapshot.test.ts
  • docs/persistence/browser-refresh.md
  • docs/chat/persistence.md
  • testing/e2e/src/routes/persistence-durability.tsx
  • packages/ai-client/src/types.ts
  • packages/ai-client/src/chat-client.ts

Comment thread docs/persistence/overview.md Outdated
Comment thread docs/persistence/overview.md
Comment thread packages/ai-persistence/src/reconstruct.ts Outdated
Comment thread packages/ai-persistence/src/reconstruct.ts Outdated
Comment thread packages/ai-persistence/src/reconstruct.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/persistence/client-persistence.md`:
- Around line 75-77: The documentation section describing server-authoritative
persistence needs a concrete server example. Add a minimal GET endpoint snippet
using reconstructChat(persistence, request), positioned alongside the existing
client usage and aligned with the documented loader flow; keep the example
focused on returning the reconstructed transcript.
- Around line 41-50: Update the “Repaints the transcript” documentation in the
client persistence overview to describe initialization-time hydration rather
than hydration before the first render. Explicitly distinguish synchronous Web
Storage, which may hydrate immediately, from asynchronous lazily opened
IndexedDB, whose transcript and resume snapshot apply only after hydration
resolves; keep the pending-interrupt and in-flight-run behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 11e079f7-59fc-4078-984b-71126e311805

📥 Commits

Reviewing files that changed from the base of the PR and between e5c4064 and 8f408cb.

📒 Files selected for processing (11)
  • docs/chat/persistence.md
  • docs/config.json
  • docs/persistence/chat-persistence.md
  • docs/persistence/client-persistence.md
  • docs/persistence/controls.md
  • docs/persistence/internals.md
  • docs/persistence/overview.md
  • docs/resumable-streams/advanced.md
  • packages/ai-persistence/src/reconstruct.ts
  • packages/ai/skills/ai-core/chat-experience/SKILL.md
  • packages/ai/skills/ai-core/middleware/SKILL.md
💤 Files with no reviewable changes (1)
  • docs/chat/persistence.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • docs/persistence/chat-persistence.md
  • packages/ai-persistence/src/reconstruct.ts
  • docs/persistence/overview.md
  • docs/persistence/controls.md
  • docs/config.json

Comment thread docs/persistence/client-persistence.md
Comment thread docs/persistence/client-persistence.md Outdated
Comment thread docs/persistence/custom-stores.md Outdated

Only those two stores move to the custom database; D1 still owns messages and
metadata. Composition does not create a transaction across those systems;
design related writes accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to have specifics for each of the events that trigger persistence and what the flow is through the persistence store at that point. (e.g. this is when/how we load the thread, this is when/how we store messages, this is what happens when there is an interrupt, etc.) Maybe as swim lanes.

Basically I don't think folks are going to have a different store for chats. They are going to want to integrate it in with the rest of the customer data. So we should invest pretty heavily in this doc.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — most apps fold chat into the existing customer DB rather than standing up a separate store.

That is now spelled out in docs/persistence/custom-stores.md under When each store is called:

  1. Chat middleware lifecycle table — which store methods fire on onConfig / onStart / stream onChunk / interrupt boundary / onFinish / onError / onAbort, and hard-fail vs best-effort.
  2. Swimlanes (request → store) — ASCII flow from client POST through load/createOrResume/listPending → saveThread/update/commit.
  3. Invariants — full-overwrite saveThread, idempotent createOrResume, insert-if-absent interrupts, authorize-at-boundary, locks via withLocks not the state bag.

If anything is still thin for the integrate-with-my-tables path (e.g. mapping each method onto a concrete threads/messages/approvals schema, or transaction boundaries across stores), say which event you want expanded and we can add a worked example in a follow-up.

AlemTuzlak and others added 16 commits July 24, 2026 10:43
…kends

Server-side persistence for chat(): durable thread messages, run records, and
interrupts via the withChatPersistence middleware, with pluggable backends.

- @tanstack/ai-persistence: store contracts, withChatPersistence /
  withGenerationPersistence middleware, memoryPersistence reference store,
  conformance testkit. Locks (LockStore/InMemoryLockStore/LocksCapability) live
  here rather than core; the sandbox-consumer bridge is deferred.
- -drizzle / -prisma / -cloudflare: backend store implementations + migration /
  schema / models CLIs. Cloudflare D1 delegates to the drizzle backend.

Reconciled against the shipped ephemeral-interrupt engine: the middleware
records interrupts and gates new input, and delegates resume-tool-state
reconstruction to the engine (resume batch + interrupt bindings in history).

Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
The persistence adapter now stores one combined { messages, resume? } record
per chat id, so a full page reload restores the transcript, rehydrates pending
interrupts, and rejoins an in-flight run through joinRun when the connection is
durability-backed. Legacy bare-array records are still read.

Adds localStoragePersistence / sessionStoragePersistence / indexedDBPersistence
(+ StorageUnavailableError and the ChatPersistedState / ChatStorageAdapter
types). Durability rides the existing option, so every framework integration
gets it with no framework-specific code.

Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
New /persistent-chat route: useChat with localStoragePersistence on the client
and withChatPersistence(sqlitePersistence) on the server, so a full page reload
restores the conversation on both ends. Adds a nav link and README section.

Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
…ility

New docs/persistence section: overview, chat-persistence, browser-refresh,
controls, custom-stores, sql-backends, drizzle, prisma, cloudflare, migrations,
internals. Wires the nav and updates the client chat/persistence page for the
combined { messages, resume } record and built-in storage adapters.

Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
Update the agent skills for the new surface: withChatPersistence server
middleware and its backends, and the client browser-refresh durability
(combined persistence record, storage adapters, joinRun rejoin, all frameworks).

Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
Provider-free durable harness route + client page + spec proving message
restore after reload and interrupt-survives-reload via localStorage. Mid-stream
joinRun rejoin is covered by ai-client unit tests and delivery-durability.

Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
Add the object form `persistence: { store, messages?: boolean }`. `messages:
false` caches only the tiny resume pointer, keeping large transcripts off the
client while durability rejoin and interrupt restore still work and the server
stays authoritative for history. A bare adapter remains shorthand for
`{ store, messages: true }`, so this is backward compatible and every framework
passthrough is unchanged.

The persistent-chat example gains a history branch on its GET route
(loadThread by threadId, distinct from the per-run delivery replay) so a
server-authoritative reload can hydrate the transcript. Docs + chat-experience
skill document the lever.

Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
Rewrite the persistence overview into a concept + decision page: the three
problems (dropped stream, lost-on-reload, no durable record), the two
independent layers (delivery durability vs state persistence), client vs server
halves, the reload/rehydration timeline, and a when-to-pick-each guide. Add a
back-link from the resumable-streams overview so the two sections cross-reference.

Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
…tence

localStoragePersistence / sessionStoragePersistence / indexedDBPersistence now
default their type parameter to ChatPersistedState and to a JSON codec, so
`persistence: localStoragePersistence()` needs no type argument and no
serialize/deserialize pair. Drops the IsJsonSerializable type gate that forced a
codec for the chat record (UIMessage already round-trips as JSON on the wire).

Client persistence now keys on `threadId` (the conversation identity), so a
reload with the same threadId restores the same record; `id` becomes an optional
storage-key override. The storage adapters and persistence types are re-exported
from every framework package, so a single import from @tanstack/ai-react (etc.)
works.

Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
reconstructChat(persistence, request) returns a thread's stored messages as a
JSON Response, so a server-authoritative client can hydrate its transcript on
load from a one-line GET handler instead of hand-rolling loadThread + Response.
…curate resume

Add a "What we recommend" section to the overview: client resume-pointer-only
plus server persistence plus one GET that rehydrates history and resumes durable
streams, with the reasoning. Update every snippet to the zero-config
localStoragePersistence() and threadId, use reconstructChat for history, and
discriminate the resume GET with durability.resumeFrom() instead of sniffing
query params (the run id rides the X-Run-Id header, the offset the Last-Event-ID
header). Example, e2e page, and chat-experience skill match.
Rename browser-refresh to client-persistence and make it the single home for
the client story: turning it on, what a reload restores, the two cache modes
(everything vs resume-pointer-only) with when to use each, and the three storage
backends with when to use each. Remove the legacy docs/chat/persistence page
(client content now lives in the persistence section) and repoint its links.

Make the other persistence docs server-only: drop the client rows from the
controls decision table and the browser-storage section from internals, leaving
a pointer to the client guide. The overview stays the cross-cutting map. Update
all cross-links, the chat-experience skill source, and the reconstructChat doc
reference.
In `{ messages: false }` mode a prior session's persisted record is
`{ messages: [], resume }`. The constructor treated that empty transcript as
authoritative and clobbered host-provided `initialMessages`, and the async
hydrate path applied `[]` on top, so a server-authoritative reload dropped the
history the app had fetched from the server.

The persisted transcript is now adopted only when the client actually caches it
(`cachesMessages`); in messages:false mode the client keeps `initialMessages`
and takes only the resume pointer from storage. This makes the recommended
server-authoritative flow work: on a mid-stream reload the app seeds history via
initialMessages (the reconstruct GET) while the client separately rejoins the
live run via joinRun (the resume GET), and the replayed run merges into the
seeded history by message id. Adds a test covering both together.

Also document in the overview that history hydration and run rejoin are two
separate GET requests, so the handler's if/else routes each and neither blocks
the other.
autofix-ci Bot and others added 13 commits July 27, 2026 02:43
Move the distributed-mutex primitive off the main @tanstack/ai barrel into a
dedicated `@tanstack/ai/locks` subpath, so locks are opt-in and clearly separated
from chat/state exports.

- New `@tanstack/ai/locks` entry (package.json exports + vite build entry) that
  re-exports LockStore / InMemoryLockStore / LocksCapability / getLocks /
  provideLocks / withLocks; removed from the main index barrel.
- Repoint consumers: @tanstack/ai-sandbox (sandbox, middleware) and the
  persistence/sandbox/ai tests now import locks from @tanstack/ai/locks.
- Docs + agent skills (locks, build-your-own-adapter, controls, overview,
  ai-persistence) and the changeset updated to the new import path.
Drop the 	s ignore on all six adapter code fences so kiira actually checks
them. Each block is now a complete, standalone, type-safe example: node:sqlite
columns are narrowed (String/Number/typeof) instead of cast, the run/interrupt
status enums are validated with a switch guard (no �s), cross-block helpers
come from relative imports, and the InterruptStore block implements the full
interface. 14/14 snippets type-check.
Add defineMessageStore / defineRunStore / defineInterruptStore /
defineMetadataStore: identity helpers that type a store implementation inline
(autocomplete + contract checking on the object literal, no : MessageStore
return annotation). They compose into defineAIPersistence, which already infers
exact presence — a defined store is a non-optional, autocompleted key on
persistence.stores and an omitted one is a compile error to access.

Adds a type test, converts the build-your-own-adapter guide and the
ts-react-chat sqlite adapter to use them, and updates the ai-persistence stores
skill.
Locks moved to @tanstack/ai/locks, but several docs still explained them by
negation relative to persistence — 'not a state store', 'don't pass into
stores', 'not composed with composePersistence', a 'Not a persistence store'
section. That only makes sense to someone who knew locks once lived there, which
no reader did. Replace those with neutral pointers that describe locks in their
own terms (a cross-instance mutex, wired with withLocks); keep the legitimate
locks guide and cross-links.
- defineLock in @tanstack/ai/locks: identity typer for a LockStore
  implementation (autocomplete, no ': LockStore' annotation), matching the
  define*Store helpers. Exported via the /locks subpath; unit-tested.
- docs/advanced/locks.md: add an 'Implement a lock' section using defineLock.
- docs/persistence/build-your-own-adapter.md: inline the RunRecord and
  InterruptRecord shapes into the RunStore / InterruptStore reference blocks so
  the record types the methods return are visible, not just named.
- Update the ai-core/locks skill with defineLock.
Make the SQLite store methods �sync instead of synchronous bodies that wrap
every result in Promise.resolve(). Void methods (saveThread, update, create,
resolve, cancel, delete) become bare statements with no explicit return; value
methods return the value directly. Same behavior, less boilerplate.
…rue`

Replace the client `persistence` option shape from
`ChatClientPersistence | { store, messages? }` with `boolean | ChatClientPersistence`:

- `false`/omitted: ephemeral, in-memory only
- `true`: server-authoritative, the client caches nothing and hydrates the
  thread from the server by `threadId` on mount
- a storage adapter: client-authoritative (unchanged)

Drops the `{ store, messages: false }` object form (and `ChatPersistenceConfig`),
whose client store was vestigial once reconnect and interrupts became
server-resolved by threadId. Removes the now-dead `storeMessages` param from
ChatPersistor and the write-only `cachesMessages` field. Migrates the example,
e2e route, tests, docs, skills, and changesets.
Reflect the persistence: boolean | store shape in overview.md: the client
half stores nothing under persistence: true (server hydrate on mount), scope
the reload-restore cases to the client-store adapter path, and drop the
removed messages lever from the next-steps link.
Make the client switch for a server-owned chat obvious up front: the minimal
client setup now shows persistence: true (pairing with withPersistence) and
lists both forms (true vs a storage adapter), instead of surfacing true only in
the recommended-stack section far below.
…e tailing works

The ts-react-chat SQLite adapter's RunStore never implemented the optional
findActiveRun(threadId), so reconstructChat always reported activeRun: null and
a hydrating client (reload, another device, or switching back to a generating
thread) restored the transcript but never tailed the in-flight run. Add it
(latest 'running' run for the thread, greatest startedAt wins).

Also fix the adapter guide and Cloudflare recipe, which shipped the same gap:
build-your-own-adapter.md now implements findActiveRun in the example RunStore
and documents it in the RunStore reference, and the Cloudflare invariants table
lists it. Otherwise anyone copying the guide builds a broken adapter out the
gate.
…Run is reported

hydrateFromServer chose between tailing an activeRun and restoring a pending
interrupt with a mutually-exclusive if/else-if, tailing first. A run that just
paused on an interrupt can momentarily still read as 'running' on the server, so
a hydrate racing that window returns BOTH an activeRun cursor and the pending
interrupt. The old order tailed the (paused) run and dropped the approval, so the
interrupt card intermittently vanished on reload. Check pending interrupts first:
a pending interrupt means the thread is paused, so restore the approval and never
tail. Adds a regression test for the both-present hydrate result.
@jherr
jherr merged commit 4ab149f into main Jul 27, 2026
10 checks passed
@jherr
jherr deleted the feat/persistence-core branch July 27, 2026 16:27
AlemTuzlak added a commit that referenced this pull request Jul 27, 2026
… (BYO)

Additive on top of the core persistence PR (#984, now in main): SandboxInstanceStore contract, InMemorySandboxInstanceStore, withSandboxInstanceStore, and a conformance testkit. withSandbox consumes it in ensure (in-memory fallback); pair withLocks (@tanstack/ai/locks) for multi-instance. Docs, skill, e2e.
tombeckenham added a commit that referenced this pull request Jul 28, 2026
…Value = any)

Revert the web-storage factory defaults to TValue = ChatPersistedState, as
shipped in #984. The any default erased type safety on every direct
adapter use (getItem returned any; a store built for one domain assigned
silently to the other's hook) and carried three oxlint suppressions —
while buying nothing for inline usage, where contextual typing infers the
value type from the persistence option regardless of the default. The one
affected pattern, a standalone store for generations, now states its type:
localStoragePersistence<GenerationResumeSnapshot>(). Doc, example, and e2e
call sites updated; runtime behavior unchanged.
tombeckenham added a commit that referenced this pull request Jul 28, 2026
…Value = any)

Revert the web-storage factory defaults to TValue = ChatPersistedState, as
shipped in #984. The any default erased type safety on every direct
adapter use (getItem returned any; a store built for one domain assigned
silently to the other's hook) and carried three oxlint suppressions —
while buying nothing for inline usage, where contextual typing infers the
value type from the persistence option regardless of the default. The one
affected pattern, a standalone store for generations, now states its type:
localStoragePersistence<GenerationResumeSnapshot>(). Doc, example, and e2e
call sites updated; runtime behavior unchanged.
tombeckenham added a commit that referenced this pull request Jul 31, 2026
* feat(persistence): client-side generation persistence

Layer a lightweight, read-only resume snapshot onto media generation.
As a run streams, the client builds a GenerationResumeSnapshot (run
identity, status, errors, result metadata + artifact refs — never media
bytes) and writes it to an optional GenerationServerPersistence store.

- ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot
  reducer; GenerationClient/VideoGenerationClient observe chunks, persist
  snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot();
  disposed guard. No resume() action (stream re-attach is PR #955).
- ai-event-client: optional threadId/runId on generation events.
- react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot
  options; expose resumeSnapshot/resumeState (+ pending/result artifacts).
- example: Persisted mode on the image generation route.
- docs: persistence/generation-persistence.md + nav entry.

Pairs with the existing withGenerationPersistence server middleware.

* docs(persistence): drop generic from generation snapshot store example

* refactor(persistence): align generation persistence API with chat

Drop the bespoke `GenerationServerPersistence` type and the `{ server }`
option wrapper. The `persistence` option is now a bare storage adapter
reusing the shared `ChatStorageAdapter` contract (aliased as
`GenerationPersistence`), so `localStoragePersistence` /
`sessionStoragePersistence` / `indexedDBPersistence` work for generations
exactly as they do for chat — matching main's ergonomics.

* refactor(persistence): infer generation store type via GenerationPersistence (no call-site generic)

* refactor(persistence): value-agnostic web-storage adapter defaults

Default `localStoragePersistence` / `sessionStoragePersistence` /
`indexedDBPersistence` to a value-agnostic `TValue` so a bare, unannotated
call works for BOTH chat and generation persistence — the consuming
`persistence` option constrains the stored value. Generation docs/example now
use `localStoragePersistence({ keyPrefix })` with no type declaration.

* docs(persistence): fix stale generation-persistence delivery guidance

PR #955 (resumable streams) is merged, so delivery durability is available
today — it was wrongly described as an unlanded future feature. Rewrite the
generation-persistence doc: the server example now wires a durability adapter
+ GET handler, and the delivery section explains that a dropped mid-generation
connection re-attaches through the same adapters useChat uses. Clarify that the
read-only snapshot carries run state (incl. runId) across reloads, while
hooks do not auto-resume on mount.

* docs(persistence): rewrite generation-persistence for clarity + when-to-use

* docs(persistence): drop redundant storage-adapter comment

* fix(persistence): generation snapshot lifecycle, hydration, StrictMode revival

- kiira: replace phantom @tanstack/ai-persistence-drizzle import with the
  hand-rolled adapter from build-your-own-adapter (CI was red on this)
- hydrate the resume snapshot from persistence.getItem on construction,
  validated via new parseGenerationResumeSnapshot(unknown) export;
  initialResumeSnapshot seed takes precedence
- namespace storage keys as generation:<id> so chat and generation clients
  sharing an id and adapter no longer collide
- write terminal snapshots on stop() (idle) and transport-level errors
  (error); reset() clears memory + removeItem; RUN_STARTED drops stale
  result/error/pendingArtifacts from the previous run; plain-fetcher runs
  now record a complete snapshot built from the fetcher result
- capture video jobId into the snapshot from video:job:created
- add schemaVersion: 1 to persisted snapshots
- gate persistence writes on material change (ignore lastEvent-only churn),
  warn once per failure transition, clear resumePersistenceError on success
- mountDevtools() revives a disposed client (React StrictMode replay);
  generate() checks disposed before mounting devtools
- onResumeSnapshotChange now receives undefined when reset() clears
- fix mojibake em dashes in 12 hook files

* fix(persistence): docs, example, changeset, React hooks, and real test coverage

- rewrite docs/persistence/generation-persistence.md around the implemented
  behavior: hydration on mount, generation:<id> keys, resumeState vs
  resumeSnapshot semantics, honest reconnect story, no media-URL claim;
  drop the inert threadId/runId spreads from the server sample
- fix the example's Persisted panel: distinguish in-flight run from last-run
  outcome; reload now actually shows the persisted record
- revert ai-event-client: BaseEventContext already carries threadId/runId,
  the 36 added lines were redundant redeclarations; changeset no longer
  bumps that package and now describes hydration + lifecycle accurately
- normalize wrong hook JSDoc (Server-side → client-side storage; read-only
  seed claims; run/cursor wording) and mark artifact fields dormant
- React hooks: post-dispose guards on callbacks/setters, StrictMode revive
  via mount effect, stable empty artifact arrays, re-export persistence
  types (+ PersistedArtifactRef)
- tests: replace the two vacuous reducer tests with real externalUrl
  positive/negative and stop coverage; add reducer seed-merge, RUN_STARTED
  stale-field-drop, video jobId capture, parseGenerationResumeSnapshot
  suite; add client lifecycle suite (hydration, seed precedence, corrupt
  storage, stop/reset/transport-error, write gating, StrictMode revive);
  add React hydration/StrictMode/artifact-exposure hook tests

* test(persistence): E2E reload-and-rehydrate spec for generation snapshots

Provider-free harness (api.generation-persistence streams a fixed AG-UI
sequence; aimock-exempt) + page using useGenerateImage with
localStoragePersistence. Proves: snapshot written under
tanstack-ai:generation:<id> with no media bytes, hydrated after reload with
no auto-run, and removed by reset().

* docs(skills): cover generation resume snapshots in client-persistence + media-generation skills

* fix(persistence): framework sweeps for Solid/Vue/Svelte/Angular + hydration ordering

- solid: build the client outside reactive tracking (untrack) — the old
  createMemo second-arg was a seed, not deps, so option reads were tracked
  and a change orphaned an undisposed client; stable empty artifact arrays
- svelte: explicit generate() now revives a disposed client (mountDevtools)
  since Svelte has no remount effect; reactive bindings revive with it
- vue: stable empty artifact array constants (shallowRef identity)
- angular: JSDoc for persistence/initialResumeSnapshot on inject-generate-video
- all four: re-export GenerationPersistence/GenerationResumeSnapshot/
  GenerationResumeState/GenerationResumeStatus/GenerationPendingArtifact +
  PersistedArtifactRef from package index; hydration + reset()/removeItem
  tests against Map-backed adapters
- ai-client: kick off snapshot hydration only after callbacksRef is
  assigned (removes a sync-adapter ordering hazard)

* ci: apply automated fixes

* refactor(ai-react): thread TInput through UseGenerationReturn, drop generate casts

UseGenerationReturn gains a defaulted second generic
(TInput extends Record<string, any> = Record<string, any>) so generate is
typed (input: TInput) => Promise<void>. useGeneration returns the type it
actually builds — the unsound internal narrow-to-wide cast and the five
wrapper-level casts back down to the concrete input type all disappear,
and direct useGeneration consumers get a precisely typed generate.
Existing UseGenerationReturn<MyOutput> references keep compiling via the
default.

* refactor: thread TInput through generation return types in solid/vue/svelte/angular

Same fix as fbc3dc3 for the remaining four frameworks: the base return
interface (UseGenerationReturn / CreateGenerationReturn /
InjectGenerationResult) gains a defaulted second generic
(TInput extends Record<string, any> = Record<string, any>) so generate is
typed (input: TInput) => Promise<void>. The base hooks return the type
they actually build and the internal narrow-to-wide casts plus every
wrapper-level 'generate as' cast are deleted. Video hooks were already
cast-free (they build their own client). Defaults keep existing
single-generic references compiling.

* refactor(persistence): restore typed storage-adapter defaults (drop TValue = any)

Revert the web-storage factory defaults to TValue = ChatPersistedState, as
shipped in #984. The any default erased type safety on every direct
adapter use (getItem returned any; a store built for one domain assigned
silently to the other's hook) and carried three oxlint suppressions —
while buying nothing for inline usage, where contextual typing infers the
value type from the persistence option regardless of the default. The one
affected pattern, a standalone store for generations, now states its type:
localStoragePersistence<GenerationResumeSnapshot>(). Doc, example, and e2e
call sites updated; runtime behavior unchanged.

* feat(persistence): durable generation media-byte storage

Layer server-side artifact + blob storage onto the client generation snapshot.
When the persistence backend provides both an artifacts (ArtifactStore) and a
blobs (BlobStore) store, withGenerationPersistence writes each generated file's
bytes to the blob store (key artifacts/<runId>/<artifactId>), records an
ArtifactRecord, attaches PersistedArtifactRefs to the result, and emits
generation:artifacts (which the client reducer already consumes).

- @tanstack/ai: result-transform machinery (resultTransforms/artifactInputs on
  GenerationMiddlewareContext, applyGenerationResultTransforms), threadId/runId
  on the image/audio/speech/transcription activities, generation:artifacts
  emission from streamGenerationResult.
- @tanstack/ai-utils: base64ToUint8Array.
- @tanstack/ai-persistence: ArtifactStore + BlobStore contracts + in-memory
  impls in memoryPersistence(); byte persistence in withGenerationPersistence
  (extractArtifacts/nameArtifact); retrieveArtifact/retrieveBlob/artifactBlobKey
  serve helpers.
- @tanstack/ai-event-client: optional threadId/runId on generation events.
- docs + changeset updated for byte storage.

* feat(persistence): two-mode generation persistence + GenerationJobStore

Give media generation the same two persistence modes useChat has, driven
by the `persistence` option:

- server-driven (`persistence: true` + a stable `threadId`): the client
  keeps no local store and hydrates the last generation job from the
  server on mount via a read-only `hydrateGeneration` GET, answered by the
  new `reconstructGeneration` helper.
- client-driven (a storage adapter): unchanged.

Server: reshape `withGenerationPersistence` off the flagged stopgap that
faked `threadId = requestId` on the chat RunStore onto a dedicated
`GenerationJobStore` keyed by `jobId` (threadId only an optional link).
Add `defineGenerationJobStore` / `defineArtifactStore` / `defineBlobStore`
and `reconstructGeneration`; durable byte storage (artifacts + blobs)
stays an optional layer on top.

Client: widen `persistence` to `boolean | adapter`, add `threadId`, and
thread both through every generation hook across react/solid/vue/svelte/
angular. `hydrateFromServer` validates the untrusted server snapshot and
only adopts it when nothing was observed locally first; a live generate()
always wins and no run is ever auto-started.

Docs (two modes + BYO job/artifact/blob stores), a Cloudflare R2
artifact/blob skill, unit tests, and a server-driven e2e spec included.

* ci: apply automated fixes

* docs(persistence): route readers to generation persistence + split byte storage

Generation persistence shipped, but nothing pointed readers to it. Fix the
discovery paths:

- Split "keep the generated files" out of generation-persistence into its own
  Keep Generated Files page (server-only byte storage is a distinct journey).
- Point the media docs at it: a callout on the generation-hooks hub and
  video-generation (minutes-long runs), lighter pointers on image/audio/
  transcription.
- Give the persistence overview a Generation persistence sibling section, add
  the jobs/artifacts/blobs stores to the store-contract table, and link the
  generation pages from "Where to go next".
- Note in client-persistence that generation hooks share the same
  true/adapter modes.

* refactor(generation-persistence): restore transparently into the normal hook fields

Generation persistence exposed a bolt-on client surface: `resumeSnapshot`,
`resumeState`, `pendingArtifacts`, `resultArtifacts`, and on restore it
repainted only `resumeSnapshot`, leaving `result`/`status`/`error` idle. Make
it invisible like chat, which restores straight into `messages`.

Client (@tanstack/ai-client + 5 frameworks):
- Hooks now return only `generate`, `result`, `isLoading`, `error`, `status`,
  `stop`, `reset`, `resumeState`. `resumeSnapshot` / `pendingArtifacts` /
  `resultArtifacts` are gone; final artifact refs live on `result.artifacts`,
  in-flight ones on `resumeState.pendingArtifacts`.
- On restore (client store or server hydrate) the client repaints
  `result` / `status` / `error` and emits `resumeState`, so a reload looks like
  a just-finished run. A per-activity `reconstructResult` mapper (image / audio
  / transcription / summarize; video built into the video client) rebuilds a
  typed result, with media resolved to the durable serve URL. Live `generate()`
  still wins over a slow restore; no run is auto-started.
- `localStoragePersistence()` / `sessionStoragePersistence()` /
  `indexedDBPersistence()` now work on a generation hook with no type argument.

Server (@tanstack/ai + @tanstack/ai-persistence):
- `PersistedArtifactRef.url` (durable app-origin serve URL). New
  `withGenerationPersistence({ artifactUrl })` stamps it onto each ref and
  rewrites the live result's media URL to it, so live and restored results both
  render media from your own origin, not the provider's expiring link.
- Text results (transcription / summarize) persist their text + usage so they
  restore too.

Docs, skills, the example, and both e2e specs updated to the transparent
surface; the e2e now asserts the restored image renders from the durable URL.

* ci: apply automated fixes

* docs(persistence): slim the generation page, move advanced material to its own page

The generation-persistence page had grown to cover everything: the two modes,
reconnecting a live stream, resumeState semantics, seeding state, securing the
hydration endpoint, and the record internals. Keep the main page a focused
two-mode quickstart (choose a mode, server-driven, client-driven) and move the
deeper material to a new "Generation Persistence: Advanced" page.

* feat(generation-persistence): rejoin an in-flight run on mount (useChat parity)

When a generation run was still streaming at reload, the client only repainted
the record; it did not re-attach to the live stream. Now it does, mirroring
useChat: on mount, when hydration reports a run still generating, the client
tails it through the durability log and finishes it in place.

- Expose the connection's `joinRun` on the generation `ConnectConnectionAdapter`
  (the SSE/HTTP adapters already implement it for chat).
- `rejoinInFlight(runId)` in the generation + video clients, reusing
  `processStream`. Triggered from the server hydrate's `activeRun` and from a
  client-driven `running` snapshot's `resumeState.runId`. A live `generate()`
  wins; each run rejoins once; the loading/abort reset is guarded so a
  stop-then-generate race can't clear a fresh run's loading flag.
- Docs: drop the "cannot re-attach on reload" caveat; the main page now states a
  dropped connection or reload rejoins automatically.

* ci: apply automated fixes

* docs(persistence): remove the generation-persistence advanced page

Its reconnect section became false once in-flight runs rejoin automatically, and
the rest (resumeState, seeding, record internals) is already covered on the main
page. Fold the one load-bearing bit — the reconstructGeneration `authorize`
tenancy note — inline into the server example and drop the page + its nav entry.

* feat(examples): shared generation run history + fix stale generation-persistence docs

Example app: every generation route now wires its hook through
`generationRunPersistence()`, which delegates to `localStoragePersistence()`
and layers a shared run-history list on top of the storage-adapter seam. The
new `GenerationRunHistory` component renders that list, so each page shows its
previous runs — run history is an app concern, and the adapter seam is where
you build it.

Docs/comments: correct three stale claims that predate the dedicated
`GenerationJobStore`.

- `internals.md` still said generation "reuses chat `RunStore` and dual-keys
  `(runId, threadId)` both to `requestId`" as a stopgap, and called artifact
  persistence a follow-up. Both shipped; replaced with what the middleware
  actually does and how the optional `threadId` link works.
- `controls.md` and `internals.md` both listed `withGenerationPersistence` as
  requiring `runs`; it requires `jobs`.
- `RunRecord`'s JSDoc glossed a run as "one agent turn within a conversation",
  contradicting every other use of "turn" in the package. A run is one
  AG-UI `RUN_STARTED` → `RUN_FINISHED` cycle: it contains many agent-loop
  turns, and one user turn may span several runs across interrupt-resume.

* refactor(persistence): rename generation job to run (GenerationRunStore, runId, providerJobId)

One generation id previously wore three names: minted as runId on the wire
(AG-UI), stored as jobId in the generation store, and handed back as runId on
hydration. 'jobId' also collided with the provider's async video job handle
sitting one field away in the same snapshot. Converge on 'run' for the AG-UI
id and reserve 'job' for provider async jobs:

- GenerationJobStore/Record/Status -> GenerationRunStore/Record/Status;
  defineGenerationJobStore -> defineGenerationRunStore; record field
  jobId -> runId (matches chat's RunStore/RunRecord.runId)
- stores.jobs -> stores.generationRuns (bundle key, validators, memory store)
- reconstructGeneration reads ?runId= (option jobParam -> runParam)
- GenerationResultSnapshot.jobId -> providerJobId (ditto
  GenerationRestoredResult); parser accepts both spellings since live
  provider results still carry jobId
- provider surfaces unchanged: VideoGenerateResult.jobId, getVideoJobStatus,
  useGenerateVideo jobId state, PersistedArtifactRef.source.jobId,
  video:job:created payload
- docs (6 persistence pages + config dates), 4 skills, changeset updated

All unreleased surface (none of it is on main), so no migration needed.

* docs: explain threads, runs, and turns across streaming, interrupts, and persistence

Add a 'Threads, runs, and turns' section to the streaming guide defining
threadId vs runId and why a turn can span multiple runs, then cross-link
it from interrupts, resumable streams, and the persistence docs. Add
mermaid diagrams for the run/interrupt/generation state lifecycles, the
persistence ER schema, and the reconnect sequences.

* docs: narrow streaming guide to threads and runs, cross-link from persistence

Rename the streaming section to 'Threads and runs': just the two id
definitions, a note that tool calls stream inside the same run, and a
mermaid diagram of one thread with three runs. Update the inbound links
from interrupts, resumable streams, and the persistence docs to the new
anchor.

* refactor(examples): drop generation run history, switch image/video to Grok Imagine

Each generation page now shows only its last run, restored from the
shared localStorage snapshot adapter (lib/generation-persistence.ts) —
the shared history list, GenerationRunHistory component, and
label/preview recording are gone.

Image and video generation move from OpenAI (gpt-image-1, sora-2) to
xAI Grok Imagine (grok-imagine-image, grok-imagine-video) in the API
routes and server functions.

* ci: apply automated fixes

* fix(persistence): don't fetch caller-supplied prompt URLs + review fixes

Byte storage had one fetch path serving two purposes: `descriptorBody`
branched on `descriptor.url` alone and never looked at `descriptor.role`,
so a prompt part with `source: { type: 'url' }` was fetched server-side and
stored, readable back through the artifact GET route. Fetching an expiring
provider result URL is the point of the feature; mirroring a caller-supplied
URL is not, and the bytes are redundant since the client already had them.

Input URLs are no longer fetched. Opting back in is `allowInputUrl`, a
predicate rather than a boolean so the check can't be skipped. Every artifact
fetch is now http/https-only, timed out (`artifactFetchTimeoutMs`) and
size-capped during the drain (`maxArtifactBytes`); input fetches also block
loopback/private/link-local hosts and refuse redirects. Output fetches skip
the host block on purpose — a self-hosted provider legitimately returns a
localhost URL. `artifactFetch` injects the fetch for egress-proxy routing.

Also from review:

- gate `emitResumeState` on a signature, so a per-chunk snapshot rebuild no
  longer re-renders every framework hook on every stream event
- guard an invalid Date before `toISOString()` in the resume snapshot reducer
- fall back to the literal payload when a data URL has a bad percent escape
- treat a non-object hydration body as a miss instead of reading `.activeRun`
  off null
- docs: authorize artifact reads by `ArtifactRecord.threadId` (404, not 403),
  drop auto-resume language for snapshot hydration, add the Mode B server
  snippet, honour `limit: 0` in the R2 sample

* refactor(persistence): rename artifact externalUrl to sourceUrl

`externalUrl` sat directly above `url` on `PersistedArtifactRef` and read
backwards: `externalUrl` is the provider's original expiring link, kept for
provenance, while the plain `url` is the durable app-origin URL that actually
serves the bytes publicly. The field named "external" was the internal one.

`sourceUrl` says what it is — where the bytes came from. It also covers the
case `providerUrl` would miss: with `allowInputUrl`, an input artifact's
source is a caller-supplied URL, not a provider's.

Straight rename, no alias: `PersistedArtifactRef` is not in the published
@tanstack/ai@0.42.0, so nothing downstream can be depending on the old name.

* feat(generation-persistence): require threadId when persistence is on

`threadId` was introduced as an optional "link to the chat conversation that
triggered this generation". It is not that — it is the generation's own scope,
the stable slot successive runs are filed under, and a workflow generating (say)
a video's start frame has no conversation anywhere near it.

Presenting it as optional produced three concrete defects:

- The fallback chain `threadId ?? id ?? generated` ends in Date.now()+random,
  rebuilt on every construction. With neither supplied, client-driven wrote a
  new localStorage key every reload (restoring nothing, orphaning the last one)
  and server-driven asked for a threadId that had never existed. Both failed
  silently.
- The two modes keyed on DIFFERENT values — client-driven on `id`, server-driven
  on `threadId` — so `id: 'a'` + `threadId: 'b'` wrote slot a and read slot b.
- `id` did double duty as devtools label and persistence key, so relabelling in
  devtools silently relocated persisted data.

`threadId` is now required whenever `persistence` is set, via a union
(`GenerationPersistenceOptions`) intersected onto each hook's parameter. It stays
optional for ephemeral generations, so the published no-persistence signature is
untouched — adding an unconditional required option would have broken every
existing call site.

Persistence now keys on the explicit `threadId` in both modes. The `?? id`
fallback survives only for the AG-UI wire thread id, which the protocol requires
even when nothing is persisted; a runtime warning covers JS callers who bypass
the type.

The union is fragile in one specific way — a plain `Omit` over it collapses the
union and the requirement silently disappears — so the options interfaces stay
non-union (keeping Pick/Omit composition working in vue/solid/svelte/angular)
and `use-generation-persistence-types.test.ts` pins the behaviour.

* fix(persistence): fail loudly when a threadId lookup needs findLatestForThread

`findLatestForThread` is optional on GenerationRunStore and was called through
`?.`, so an adapter that does not implement it produced `undefined ?? null` —
indistinguishable from an ordinary 'no run found'. A server-driven client would
therefore restore nothing, forever, with no error anywhere to explain why.

Throw instead, and only on the path that actually needs the method: an explicit
`?runId=` lookup never calls it and keeps working on a minimal adapter.

* feat(persistence): storageKey for blob paths, and blobKey on the record

Generated bytes were written to a hardcoded `artifacts/<runId>/<artifactId>`
with no way to influence it, so "keep my generated files in my own R2 folder
structure" was not expressible. `withGenerationPersistence` now takes a
`storageKey` mapper receiving the artifact's identity, role, activity, mime type
and resolved name.

Server-side only, deliberately: a key supplied by the browser would be a
path-traversal and cross-tenant-write vector, the same class as the two issues
already fixed on this branch.

This forces a companion change. `retrieveBlob` RECOMPUTED the path from runId +
artifactId, which only works while the derivation is a fixed constant — the
moment it is user-supplied the read looks in the wrong place. The resolved key is
therefore recorded on the new `ArtifactRecord.blobKey`, and reads go through
`resolveArtifactBlobKey`, which falls back to the old convention for records
written before the field existed. That fallback is what makes this a
non-breaking addition, and also why the default convention can never be changed
retroactively.

Worth having independently of `storageKey`: with the key recomputed rather than
remembered, the default convention was effectively frozen forever — changing
`artifactBlobKey` would have orphaned every blob already written.

Also threads the required `threadId` through the docs, skills, E2E harness and
example call sites, and documents both new capabilities in the changeset.

* feat(persistence): server-side generation persistence in the example; require store methods

The example demonstrated only the client-driven half of generation persistence.
`withGenerationPersistence` and `reconstructGeneration` had never been run
against each other over HTTP anywhere — each was unit-tested in isolation, and
the e2e harness deliberately hand-builds the hydration JSON rather than pull in
`@tanstack/ai-persistence`. That left the join between them, which this branch
just changed the key of, as the least-covered part of the feature.

`/api/generate/image` now runs the real thing: `withGenerationPersistence` with
byte storage and an `artifactUrl`, plus a GET that serves artifact bytes by id
or answers mount hydration. The Streaming variant switches to `persistence:
true`; Direct and Server Fn keep the client adapter because server functions
have no GET path for server-driven restore to use.

Make three store methods required, per this file's own evolution policy:

- `GenerationRunStore.findLatestForThread` was optional and feature-detected —
  the exact anti-pattern the policy documents, and the exact bug it records
  `findActiveRun` causing for a release cycle. Server-driven hydration calls it
  on every mount, so an adapter without it was indistinguishable from a thread
  with no runs: `persistence: true` silently restored nothing, forever. The
  runtime guard added earlier on this branch is deleted — the compiler enforces
  it now, and the cases those tests covered are unrepresentable.
- `ArtifactStore.delete` / `deleteForRun` were optional while their pair
  `BlobStore.delete` is required, so a backend could drop the bytes but keep the
  record. An app calling `stores.artifacts.delete?.(id)` for an erasure request
  would silently no-op.

Also documents `blobKey` in the adapter guide's reference record and ER diagram,
where `ARTIFACT ||--|| BLOB` was a derived convention and is now a real key, and
deletes `api.interrupts.test.ts` — 19 assertions no runner has ever executed
(the example's vitest config scopes to `src/lib/**`), which also cost a
route-scanner warning on every dev start.

* fix(example): keep generation persistence across HMR re-evaluation

The module-level `memoryPersistence()` was rebuilt every time Vite
re-evaluated this module, so any artifact URL already stamped into a rendered
result 404'd on the next file save — the image broke even though its b64Json
was still present, because the UI prefers `img.url`. Stash the instance on
globalThis so one dev session keeps one store.

* feat(persistence): sqlite generation stores + conformance coverage

Finish the example's `node:sqlite` adapter for generations: the schema and
row types were in place, the store implementations were not.

- `GenerationRunStore`: idempotent `createOrResume` via ON CONFLICT DO
  NOTHING, dynamic-SET `update` over the JSON columns, `findLatestForThread`
  on the (thread_id, started_at DESC) index.
- `ArtifactStore`: upsert `save` persisting `blobKey`/`sourceUrl`, run-scoped
  `list` / `deleteForRun`.
- `BlobStore`: bytes in a BLOB column, keyset-cursor `list`. Prefix matching
  uses `substr(key, 1, length(?)) = ?` rather than LIKE — SQLite's LIKE is
  case-insensitive for ASCII and treats %/_ as wildcards, both of which break
  the contract's literal, case-sensitive prefix rule.

The factory returns a fully-spelled seven-store `AIPersistence`, so one
instance backs both `withPersistence` and `withGenerationPersistence`, and
the example's generation route now runs on it instead of `memoryPersistence()`
— generated images survive a dev-server restart, which is what the reverted
HMR workaround was standing in for.

Extend `runPersistenceConformance` to `generationRuns` / `artifacts` / `blobs`
so the generation half is held to the same gate as chat. Because the suite
fails loudly on an undeclared missing store, a chat-only adapter now passes
`skip: ['generationRuns', 'artifacts', 'blobs']`; the adapter-building skills
and the build-your-own-adapter guide are updated to match.

Also fixes the pre-`blobKey` artifact schema still shown in the docs and the
Cloudflare artifact-store skill (`external_url`, no `blob_key`) — copying it
made any artifact written with a custom `storageKey` unreadable, since the
key can no longer be recomputed.

* feat(example): server-side generation persistence on every activity

Image was the only route running `withGenerationPersistence`; video, audio,
speech and transcription streamed straight through, so their media lived only
at the provider's expiring URL and a restored run had nothing to render.

All five now persist. Bytes are served by ONE shared route — `/api/artifacts`
— instead of a per-route `?artifact=` branch: artifacts are addressed by id
and carry their own `mimeType`, so nothing about serving them is
activity-specific, and the authorization check a real deployment needs lives
in one place. `artifactServeUrl` points there and every route passes it as
`artifactUrl`, so results are rewritten to our origin.

The image route's GET is now purely `reconstructGeneration` mount hydration.

Audio/speech/transcription keep their zod validation and typed 400s; they gain
`generationParamsFromBody` to lift `threadId` / `runId` off the AG-UI envelope
so runs are filed under the scope the client hydrates by. Video reads its
adapter arguments off `data` as before — `size`/`model` are adapter-specific
unions the provider-agnostic video input widens to `string` — and uses the
helper for identity only.

Transcription produces text, not media: what it persists is the run record
plus the input audio artifact.

* fix(example): make generation routes resumable so a refresh can rejoin

Refreshing mid-generation surfaced "Stream response body read failed".

Resumability is automatic on the CLIENT and opt-in on the SERVER. On mount the
client re-attaches to a run it believes is still going by issuing
`GET <route>?offset=-1&runId=…`. None of the generation routes had a GET, so
Start's catch-all answered with the SPA's HTML shell, which the client then
failed to parse as SSE — surfacing a raw transport error (StreamReadError) in
place of anything actionable.

Every streaming generation route now opts in, per the resumable-streams guide:
chunks are logged and id-tagged through `memoryStream` on the response, and a
GET replays the log. An unknown or aged-out run now answers with a RUN_ERROR
event on a real `text/event-stream` instead of HTML.

Video additionally detaches its run from the request (`startDetachedGeneration`
in the new lib/generation-durability), so a reload cannot kill a multi-minute
job — the producer keeps going and the reader is what gets cancelled. That is
the persistent-chat route's policy and it is deliberately NOT applied to the
short activities: a detached run keeps billing after the user leaves, and an
image or a speech clip is cheaper to re-run than to keep alive.

The image GET now serves two jobs in order, like the chat route: delivery
replay when the request carries a resume offset, otherwise `reconstructGeneration`
mount hydration.

* fix(example): let nitro's dev middleware serve /api to subresources

`nitro/dist/_build/vite.dev.mjs` classifies a request as a static asset from
`Sec-Fetch-Dest`: anything that isn't `document`/`iframe`/`frame` falls through
to vite's static middleware, which has no file and 404s with connect's
`Cannot GET` page. The extension branch only applies when the header is absent
or `empty`, so renaming the route doesn't help.

That makes every artifact URL unloadable in dev: `<img src="/api/artifacts?id=…">`
sends `Sec-Fetch-Dest: image` and 404s, while the same URL fetched from JS
(`empty`) returns the bytes. It only bites routes served under Start's
catch-all `/**`, which is all of them here.

A pre-plugin presents `empty` for our own `/api/` paths, routing them back to
the server without changing what the browser sends. Dev-only — this middleware
does not exist in a production build.

* feat(persistence): server-driven generation persistence over server functions

Server-driven persistence (`persistence: true`) previously required an HTTP
endpoint, because the hydrate/rejoin handlers lived on the connection adapter
and only the fetch/XHR adapters implemented them. A TanStack Start server
function had no way to participate, so `persistence: true` silently restored
nothing there.

Persistence handlers are now supplied independently of the transport:

- `stream()` takes an optional second argument of `{ hydrate,
  hydrateGeneration, joinRun }`, spread onto the adapter.
- The generation client accepts `hydrateGeneration` / `joinRun` as options,
  used when the connection carries none. The connection's handlers win when
  both exist, and `persistence: true` with no handler from either source warns
  instead of silently no-opping.
- `memoryStream` accepts an explicit `{ runId, offset }` alongside a `Request`,
  and the new `replayRunStream` replays a run's delivery log as a bare chunk
  stream — what a server function needs to serve `joinRun` without an HTTP
  `Response`.

A restored snapshot that reports a run still in flight is now repainted through
one path: tail it via `joinRun` when a handler exists, otherwise repaint it as
an interrupted error rather than a `generating` status that would never settle.

The generation hooks across React, Solid, Vue, Svelte and Angular forward the
new options.

* Merge remote-tracking branch 'origin/main' into feat/generation-persistence-full

* fix(ai): apply result transforms and carry identity in streaming generateVideo

A persisted video restored as nothing on reload. The run record showed
`status: 'complete'` and nothing else — no result metadata, no artifact refs,
no stored bytes, and `thread_id` NULL.

Streaming video was the only media activity that never called
`applyGenerationResultTransforms`, and never put the caller's `threadId` /
`runId` on the middleware context. `withGenerationPersistence` registers BOTH
its artifact capture and its run-record `result` write as result transforms,
pushed onto an OPTIONAL `ctx.resultTransforms` — so both silently no-opped,
and the run was filed under the internal `requestId` with no thread link. The
client rebuilds a restored video from an output artifact carrying a durable
url, found none, and restored nothing.

Video now applies the transforms to its terminal result before yielding it, so
the `generation:result` chunk and the stored record carry the same urls
(including the app-origin one `artifactUrl` stamps), and passes `threadId` /
`runId` / `artifactInputs` into the context like `generateImage`.

`threadId` is now a documented option on `generateVideo`. It previously had
none, so callers passing one through an object spread type-checked and were
silently ignored — which is how the example's route looked correct while
recording NULL. When omitted, an id is still minted for the RUN_* wire chunks,
but the middleware context gets `undefined` instead: a fabricated thread id is
a slot no client can hydrate by, which is worse than no link at all.

Both regression tests fail against the previous behaviour.

* feat(persistence)!: require threadId on withGenerationPersistence

The client hooks require `threadId` whenever `persistence` is set; the server
middleware did not. That asymmetry hid a class of silent failure: a run filed
under no scope cannot be hydrated by one, so `persistence: true` restored
nothing, forever, with no error to explain why. The example's video route hit
exactly this — its runs recorded `thread_id: NULL`.

`withGenerationPersistence(persistence, { threadId, ... })` now takes a
required `threadId` via the new `WithGenerationPersistenceOptions`, mirroring
the client's discriminated union.

The option is also the AUTHORITY for the run record's and artifacts' scope, in
preference to `ctx.threadId`. An activity mints a throwaway thread id for its
RUN_* wire chunks when the caller passes none, and persisting that fabricated
id filed runs in a slot nothing could look up — worse than recording no link,
because it looks like one. A test that asserted the old fallback (wire id ==
persisted id) now asserts they deliberately diverge.

Call sites updated across the example routes, docs and skills. The example
routes reject a request carrying no `threadId` with a 400 rather than inventing
one, which is the pattern the docs now show.

Note: `docs/persistence/generation-persistence.md` has one remaining kiira
failure in the `getImageHydrationFn` snippet (a `ReconstructedGeneration` /
Start `ServerFn` return-type mismatch). It predates this commit — verified by
stashing these changes — and is left alone.

* fix(generation): make runs survive client disconnect and resume mid-run

Durability decouples the producer from the HTTP response so a durable run keeps draining to the log after a reload; RUN_STARTED flushes immediately so one-shot activities are resumable from the start; summarize threads runId through chat (openai-base honors options.runId) so its delivery log aligns with the client's rejoin; TTS restores via reconstructSpeechResult; a failed rejoin settles to error instead of stuck-generating; dispose keeps the run resumable; OpenAI reasoning models drop unsupported temperature/top_p.

Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh

* feat(example): persistent-generation route; rely on library durability

New /generations/persistent-generation page wiring all six generation hooks (server-driven for the five media, client-driven for summarize). Server routes now fall back to reconstructGeneration on GET, and the video route drops the hand-rolled startDetachedGeneration/tailGenerationResponse in favor of the plain toServerSentEventsResponse(stream, { durability }) path now that the library owns run lifetime. Summarize route adds delivery durability + a resume GET and threads runId.

Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh
tombeckenham pushed a commit that referenced this pull request Jul 31, 2026
… (BYO)

Additive on top of the core persistence PR (#984, now in main): SandboxInstanceStore contract, InMemorySandboxInstanceStore, withSandboxInstanceStore, and a conformance testkit. withSandbox consumes it in ensure (in-memory fallback); pair withLocks (@tanstack/ai/locks) for multi-instance. Docs, skill, e2e.
tombeckenham pushed a commit that referenced this pull request Jul 31, 2026
* feat(ai-sandbox): durable sandbox instance store + shared lock tokens (BYO)

Additive on top of the core persistence PR (#984, now in main): SandboxInstanceStore contract, InMemorySandboxInstanceStore, withSandboxInstanceStore, and a conformance testkit. withSandbox consumes it in ensure (in-memory fallback); pair withLocks (@tanstack/ai/locks) for multi-instance. Docs, skill, e2e.

* fix(sandbox): align instance durability with main patterns

- defineSandboxInstanceStore helper (defineLock / defineMessageStore style)
- Skill and comments use @tanstack/ai/locks (not main barrel)
- Conformance suite wired for InMemorySandboxInstanceStore
- Middleware resume test via withSandboxInstanceStore + withLocks + withSandbox
- Locks doc links to sandbox instance durability

* ci: apply automated fixes

* refactor(sandbox): take the instance store as a withSandbox option

Replaces the withSandboxInstanceStore pass-through middleware with withSandbox(sandbox, { instances, locks? }). The store had exactly one reader, so routing it through the capability bus bought nothing and cost an ordering rule whose violation silently degraded to the in-memory fallback. SandboxInstanceStoreCapability + provideSandboxInstanceStore stay exported for ambient/platform wiring; precedence is option -> bus -> in-memory.
AlemTuzlak added a commit that referenced this pull request Aug 4, 2026
* feat(ai-sandbox): durable sandbox instance store + shared lock tokens (BYO)

Additive on top of the core persistence PR (#984, now in main): SandboxInstanceStore contract, InMemorySandboxInstanceStore, withSandboxInstanceStore, and a conformance testkit. withSandbox consumes it in ensure (in-memory fallback); pair withLocks (@tanstack/ai/locks) for multi-instance. Docs, skill, e2e.

* fix(sandbox): align instance durability with main patterns

- defineSandboxInstanceStore helper (defineLock / defineMessageStore style)
- Skill and comments use @tanstack/ai/locks (not main barrel)
- Conformance suite wired for InMemorySandboxInstanceStore
- Middleware resume test via withSandboxInstanceStore + withLocks + withSandbox
- Locks doc links to sandbox instance durability

* ci: apply automated fixes

* refactor(sandbox): take the instance store as a withSandbox option

Replaces the withSandboxInstanceStore pass-through middleware with withSandbox(sandbox, { instances, locks? }). The store had exactly one reader, so routing it through the capability bus bought nothing and cost an ordering rule whose violation silently degraded to the in-memory fallback. SandboxInstanceStoreCapability + provideSandboxInstanceStore stay exported for ambient/platform wiring; precedence is option -> bus -> in-memory.

* feat(ai): core run lifecycle types and InMemoryRunStore

* refactor(ai-sandbox-cloudflare): own the legacy run event log

* feat(ai): StreamDurability.append accepts caller-supplied offsets

* feat(ai): declare AbortInfo.cancelRequested

* refactor(ai-sandbox-cloudflare): own the legacy run driver

* feat(ai-durable-stream): reject caller-supplied append offsets

Core's append() was widened to accept an opts.offsets array so callers can
upsert deterministic ids (Wave 1). durableStream cannot honor that: its
offsets are encodeCursor({ backendOffset, seq }), where backendOffset comes
from the backend's Next-Offset response header and seq is assigned locally
from nextSeq — there is no protocol slot for a caller-assigned id.

Because the returned object is typed StreamDurability<DurableStreamOffset>,
a narrower one-parameter append implementation stayed assignable to the
widened two-parameter member, so TypeScript would not have caught a caller
passing offsets and having them silently dropped (backend would assign its
own, and a Phase 2 re-translating successor host would duplicate replayed
events). This widens the implementation signature to accept opts and throws
DurableStreamError as the very first statement — before the chunks.length
early return and before ensureCreated() — so a rejected call has no
network side effect. Chose fail-loud over a length-mismatch validation
(as core's memoryStream does) because once any offsets are rejected
outright, a length check is unreachable and would misleadingly imply
offsets are a partially-supported path.

* refactor(ai-persistence): re-export core run types

* feat(ai-persistence): MemoryRunStore listByThread and listReclaimable

* refactor(ai-sandbox): drive runs through core RunStore and StreamDurability

* test(ai-sandbox): restore run-driver coverage for error, mid-abort, and attach

* test(ai-persistence): conformance coverage for optional run-store methods

* feat(ai-sandbox)!: remove the duplicate RunEventLog

`@tanstack/ai-sandbox` carried its own run event-log and run-lifecycle
vocabulary alongside core's. Both consumers moved off it earlier in this
phase, so the duplicate goes away.

`RunStatus`, `TerminalRunStatus`, `RunRecord`, `RunError` and
`isTerminalRunStatus` are removed with NO replacement re-export.
Re-exporting core's versions here would recreate exactly the
two-import-paths-for-one-type duplication this phase exists to remove —
consumers import run lifecycle types from `@tanstack/ai`.

The event-log concepts (`RunEventLog`, `InMemoryRunEventLog`, `RunEvent`,
`RunError`, `RunEventLogReadOptions`) have no core equivalent and now live in
`@tanstack/ai-sandbox-cloudflare`. Their nine contract tests moved with them
rather than being dropped: `run-driver.test.ts` only uses
`InMemoryRunEventLog` as a fixture, so deleting the suite would have lost the
only coverage of gap-free sequencing, exclusive-cursor resume, blocked-reader
wake, read-signal abort, and append-after-terminal rejection.

`RunDeps` is now exported from './run'.

BREAKING CHANGE: `@tanstack/ai-sandbox` no longer exports `RunEventLog`,
`InMemoryRunEventLog`, `RunEvent`, `RunError`, `RunEventLogReadOptions`,
`RunStatus`, `TerminalRunStatus`, `RunRecord`, or `isTerminalRunStatus`.

* test(ai-persistence): tighten listReclaimable conformance with negative fixtures

* docs: changeset for unified run lifecycle types

* docs(ai-core): skills cover core-owned run lifecycle types

* docs(persistence): the run record is shared across layers

* docs(ai-sandbox): skill documents RunDeps and core run types

* docs(resumable-streams): append accepts caller-supplied offsets

* docs(persistence): run store contract and its optional methods

* docs(ai-persistence): skills cover the core run store and its optional methods

* docs: refresh updatedAt for the run-store and durability pages

* docs(resumable-streams): async reject sample and accurate durableStream framing

* docs(persistence): complete the SQLite run store and clarify interrupted vs aborted

* test(ai-durable-stream): pin the offsets guard ahead of stream creation

* fix(ai-durable-stream): type-only import in the type test

* fix(ai)!: RunError shape, narrowing terminal guard, and accurate run-store docs

* refactor(ai)!: replace append offsets parameter with an optional upsert method

* feat(ai): export UpsertableStreamDurability from the barrel

* docs(ai-sandbox-cloudflare)!: accurate swap-safety comments and Legacy-prefixed public types

Correct comments in coordinator.ts and run-log.ts that claimed tsc cannot
catch a swapped isTerminalRunStatus import: it can, since the two RunStatus
unions are disjoint apart from 'aborted', rejecting the swap at every call
site in both directions. Also correct the watchdog runtime-consequence
claim: a swap would cause a bogus failStalledRun sweep over every historical
terminal record on each alarm tick (a growing cost), not "never release the
instance" (ctx.waitUntil(done) holds the instance, not the alarm).

Rename RunStatus, TerminalRunStatus, RunRecord, and RunError to a Legacy
prefix on the way out of agent.ts, since @tanstack/ai now publicly exports
the same three type names (RunError is also shared) with different
meanings, so the "keeping it module-internal removes the collision
entirely" claim was false for these public type re-exports. RunEventLog,
RunEvent, and RunEventLogReadOptions have no core equivalent and keep their
names.

* refactor(ai-durable-stream)!: drop the offsets guard, express the capability in the type

* fix(ai-persistence)!: structured run errors and declare-or-fail conformance skips

* fix(ai-sandbox): pipeToRunLog terminalizes and logs on every failure path

* docs: correct the run-types changeset for the reviewed surface

* docs: upsert capability and structured run errors

* docs(skills): structured run errors, upsert capability, declare-or-fail conformance

* fix: close the round-2 review findings

Three blocking findings from the second review round, two of which were
re-introduced by the first round's own fixes.

- `memoryStream.upsert` planned the batch with `entries.map`, which SKIPS holes
  in a sparse array. The plan came out short, so the apply loop read `undefined`
  partway through, after earlier steps had already mutated the log. That is the
  partial-application bug the redesign was meant to remove, relocated from the
  deleted `opts.offsets` into `entries`. Plan with `Array.from` so every index is
  visited and a hole is rejected before anything is touched. The interface doc
  claimed a hole was unrepresentable; it is not, so it now says implementations
  must reject one.

- `pipeToRunLog`'s new `logger` was the one dep in `RunDeps` left unguarded.
  Every logger call sits inside a `catch` body, so a throwing sink escaped it,
  skipped `durability.close()`, and wedged the run at `running` with live
  tailers parked: exactly the failure the logger was added to report on. All
  calls now route through `safeLog`. A consumer-supplied sink is handed
  arbitrary thrown values, so this is reachable, not theoretical.

- Two skill files told adapter authors the conformance testkit feature-detects
  optional methods and skips what is missing. It throws unless the omission is
  declared in `skipMethods`. Each contradicted a correct paragraph in the same
  file, and both sit in the `packages/**/skills/**` surface kiira does not check.

Also: a failing `runs.createOrResume` was logged as "the run stream failed"
though the stream never ran, and `upsert` returned offsets rebuilt from the
input rather than from the validated plan.

* docs(ai-persistence): fix the conformance snippet's MakePersistence shape

The skill's new skipMethods sample passed `() => ({ persistence })`, wrapping
the persistence in an object. `MakePersistence` returns the persistence itself,
so the snippet did not compile. Every other sample in the file and the real call
site use the bare form.

This surface is outside kiira's include (`docs/**/*.md`), so nothing caught it
mechanically.

* feat(ai-sandbox)!: declare killableProcesses on SandboxCapabilities

* feat(ai-sandbox): journal paths and shell command composition

* feat(ai-sandbox): byte-exact journal decoding and line splitting

* feat(ai-sandbox): deterministic run-scoped ids and chunk fingerprints

* feat(ai)!: add a bounded snapshot read to StreamDurability

* feat(ai-sandbox-*): declare killableProcesses on every bundled provider

Each provider's spawn/kill implementation was audited against the new
required SandboxCapabilities.killableProcesses flag: local-process, docker,
vercel, sprites, and daytona forcibly terminate a spawned process via
kill()/signal abort; cloudflare's kill() is a no-op and drops the abort
signal on both exec and spawn, so it is declared false.

* feat(ai-sandbox): journal reader with follow and bounded-poll strategies

* feat(ai-sandbox): align a replayed journal against the stored log

* fix: implement snapshot and killableProcesses across every implementer

* feat(ai-sandbox): journaled spawnNdjson and journal exports

* feat(ai-claude-code): deterministic ids on the journaled path

* feat(ai-codex): deterministic ids on the journaled path

* feat(ai-grok-build): deterministic ids on the journaled path

* test(ai-sandbox): journal conformance suite for real providers

* test: cover the journaled-path id wiring in codex and claude-code

* fix(ai-sandbox): stream the journal follow path instead of buffering it

* test(ai-grok-build): unique runId per invocation in the wiring test

Journals live at a real host path (/tmp/tanstack-runs) outside the test's own
sandbox cleanup and are append-only by design, so a fixed literal runId appends
to the previous run's journal. The reader then stops at THAT run's __exit
sentinel and observes a stale run's events, whose ids match the same literal
pattern, so the test passed for entirely the wrong reason. Confirmed live: the
file held four runs stacked behind four sentinels.

Also drops this run's journal best-effort so /tmp does not grow unbounded.

* feat(ai-sandbox): delete a run's journal once the run is terminal

* fix(ai-sandbox-local-process,ai-sandbox-docker): stream-decode spawn stdout

* docs(skills): the run journal, reader strategies, and snapshot

* docs: changeset for the run journal and bounded snapshot

* docs(sandbox): the run journal, and durability corrections

* test: assert journal wiring from the command, not the file

The wiring tests proved `journal: { runId }` reached `spawnNdjson` by checking
the journal file existed after the run. Journal cleanup on the terminal sentinel
landed afterwards, so that check now fails: a correctly journaled run and an
unjournaled one both leave no file behind.

Assert the spawned command instead. A recording wrapper on the sandbox handle
collects every spawn/exec, and the test requires at least one command naming the
journal path with an append redirect. That is unambiguous, independent of
post-run file state, and is the thing a regression would actually change.

Mutation-verified: removing `journal: { runId }` fails with
"expected 0 to be greater than 0".

* feat(ai): run cancel vocabulary and the driverEpoch fencing field

* feat(ai-sandbox): bounded out-of-band tolerance in journal alignment

* refactor(ai-sandbox)!: RunDeps.durability becomes a per-run factory

* feat(ai-sandbox): the sandbox run durability option seam

* test(ai-sandbox): shared middleware, log, and chunk fakes

* fix(ai-persistence)!: onAbort writes aborted; interrupted is no longer terminal-shaped

* fix(ai): populate AbortInfo.cancelRequested from the abort reason

* feat(ai): optional run driver on the resume helpers

* feat(ai-sandbox): single-writer run claim with epoch fencing

Nothing may read a journal for a run it has not claimed: `snapshot()`
carries no lock, so two hosts driving one run both compute a "remainder"
and both append it, duplicating offsets. The client de-dups by offset
only while the stream processor appends text and tool-call arguments
unconditionally, so a duplicate doubles message text and corrupts tool
JSON.

Three layers: the lease (whole drive inside `locks.withLock`), the epoch
(`RunRecord.driverEpoch` bumped per claim, re-checked on the append
path), and a quiescence gate over `snapshot()` (never `read()`, since a
taken-over log is open by definition).

The epoch re-check counts APPENDS, not elapsed time:
DEFAULT_EPOCH_RECHECK_APPENDS = 32. `pipeToRunLog` appends one chunk per
call, so a time-based interval couples the fence's resolution to the
chunk rate (a 2s interval at 500 chunks/sec lets ~1000 chunks land). The
count bounds a superseded driver to at most 31 further chunk batches at
any rate.

Mutation-tested (10 mutations, each reverted):
  1 signal.aborted -> false ........ FAIL "throws RunClaimLostError ... lease signal aborts"
  2 assertHeld after append ........ FAIL same test + 4 more
  3 observed > -> >= ............... FAIL "passes appends through while the claim holds"
  4 observed > -> < ................ FAIL "throws when the stored epoch has moved past the one held"
  5 (epoch ?? 0)+1 -> epoch ?? 1 ... FAIL "increments a pre-existing epoch..."
  6 bump before terminal check ..... FAIL "refuses a terminal run..."
  7 catch { return } -> throw ...... FAIL "treats an unreadable store as 'still mine'..."
  8 fence close() too ............... FAIL "leaves resumeFrom, read, snapshot and close untouched"
  9 current === -> >= .............. FAIL "retries while the log is still growing" + "gives up..."
 10 remove probe bound ............. run never completes (killed at 100s)

Honest scope: with `InMemoryLockStore` the two claims are serialized by
the lock, not concurrent, so `awaitLogQuiescence` can never observe a
predecessor still writing in a unit test. Layer 3 is unproven here and
documented as such; the tests prove layers 1 and 2, including the
adversarial `InMemoryLockStore` case where the lease signal never aborts
and the epoch is the only fence.

* feat(ai-sandbox): journal option and alignment wiring for adapters

* test(ai-persistence): conformance covers the durable run fields

* feat(ai-sandbox): detach a durable run on disconnect instead of destroying it

* fix(example): persist the durable run fields in the SQLite backend

* test(ai-persistence): conformance pins undefined vs false on the durable fields

* feat(ai-sandbox): sandboxRunDriver wires the claim and the run log

* fix(ai-sandbox): a refused append permanently closes the fence

* refactor(ai-sandbox): ship makeFakeShellSpawn from the testkit subpath

* feat(ai-sandbox): export the durable-run surface from the barrel

* fix(ai-sandbox): a superseded driver cannot terminalize the run record

Fencing the event log only moved the harm. A superseded driver's refused
append throws RunClaimLostError, pipeToRunLog catches it and calls
finish(ctx, 'failed', ...), and that runs.update was unfenced -- so a host
that had lost its claim could not poison the successor's log but still
marked the successor's live run terminal. isTerminalRunStatus then answers
true for a run that is streaming, which findActiveRun, the resume driver's
skip-if-terminal check, and the Phase 4 reaper all branch on.

fenceRunStore closes that seam: a terminal record write (gated on
isTerminalRunStatus, so completed/aborted are covered too) is SUPPRESSED --
resolving without writing, never throwing -- when the claim is lost. It
shares one per-claim latch with fenceDurability so the two seams cannot
disagree, and fenceDurability keeps its signature.

Deliberately not fenced: close() (a wedged 'running' record with tailers
parked forever is worse), non-terminal bookkeeping (it cannot make a live
run look finished, is self-healing, and suppressing createOrResume would
strand the row), reads, and another run's record. A failed epoch read is
not treated as loss, for the same reason.

* feat(ai-opencode): thread the durable runId through the journal and attach paths

* feat(ai-acp): route runId resolution through resolveDurableRunId (durable: false)

ai-acp does not journal yet, so the caller-runId enforcement stays off; this
just inherits the shared helper (packages/ai-sandbox/src/durability.ts) so
journaling here, whenever it lands, gets the requirement for free instead of
re-deriving it. Per the Phase 3 plan's Task 5, not the generic per-adapter
"thread journal/attach" brief -- this package has no journal or attach seam.

* docs(sandbox): document durable, reconnectable agent runs

* feat(ai-grok-build): thread the durable runId through the journal and attach paths

Journal options now derive from the resolved durable run id, and an attaching
run aligns its replayed chunks against the stored event log.

The alignment seam is `translateThreadEvents` in `chatStreamNdjson`, NOT the
`mergeChunkStreams` call in `chatStreamAcp`. Those are two different methods on
two different wire protocols: only the NDJSON path calls `spawnNdjson`, so only
it writes a journal and only it has a stored log to replay against. Wrapping the
ACP merge would align against a log that path never produced.

`emitDiffChunks` stays outside the wrap: a live `git diff` is new output from
this host, not a replay of journaled bytes.

Non-durable behavior is unchanged - `journalOptionsFor` returns undefined and is
spread away, so `spawnNdjson` keeps its original unjournaled path, and
`alignedIfAttaching` passes the stream through untouched.

* feat(ai-claude-code): thread the durable runId through the journal and attach paths

Reads SandboxDurabilityCapability off the capability bus and routes runId
resolution through resolveDurableRunId (throws DurableRunIdRequiredError when
durability is wired without a caller-supplied runId), builds spawnNdjson's
journal option via journalOptionsFor (omitted entirely on a non-durable run),
and wraps the merged translate+bridge stream in alignedIfAttaching so an
attaching run replays and aligns against the stored log instead of starting
a fresh agent.

* docs(ai-sandbox): the run driver's JSDoc example compiles

sandboxRunDriver's @example passed drive's AbortSignal straight to
chat(), which takes an AbortController, not a signal. Bridge the two
with the same controllerFor helper docs/sandbox/takeover.md already
uses for this exact seam.

* feat(ai-codex): thread the durable runId through the journal and attach paths

Resolve runId via resolveDurableRunId (enforced only when the sandbox
durability capability is wired), pass journalOptionsFor(durability, runId)
to spawnNdjson so journaling is gated on that capability rather than on
runId alone, and wrap the merged translator/bridge stream in
alignedIfAttaching so an attach run replays against the stored log.

Also fixes an omitted call site: the stdin-fallback prompt-file path
re-derived a second, independent id (options.runId ?? this.generateId())
instead of reusing the already-resolved runId, so the two could silently
diverge for a non-durable run.

translate-determinism.test.ts's capability helper now wires a resolved
SandboxDurabilityCapability, since journaling is no longer triggered by a
bare runId post-Phase-3.

* chore: changeset and E2E guidance for durable agent runs

* docs(ai-persistence): skills teach the durable-run abort semantics

- server/SKILL.md: onAbort no longer claims a disconnect always writes
  'interrupted'. It writes 'aborted' (terminal) only on an explicit cancel
  or a non-detachable run; a plain disconnect on a detachable run writes
  nothing, leaving the record 'running' for a later takeover. Documents the
  chat vs. generation onAbort asymmetry and points at isTerminalRunStatus.
- stores/SKILL.md: adds the missing driverEpoch field to the documented
  RunStore#update patch type, explains all four durable-run fields
  (sandboxKey, detachedSince, cancelRequested, driverEpoch), the
  undefined-vs-false/'field' in patch distinction, and the out-of-band
  cancel primitives (requestRunCancel/wasCancelRequested/RUN_CANCEL_REASON).
- build-{drizzle,prisma,cloudflare,custom}-adapter/SKILL.md: add the
  driverEpoch column/field everywhere sandboxKey/detachedSince/
  cancelRequested already appear, and fix the update() bodies to check
  'field' in patch instead of patch.field !== undefined so an explicit
  clear (detachedSince: undefined) actually writes NULL instead of being
  silently dropped.

* docs(sandbox): align the sandbox docs with durable runs

* feat(ai-grok-build): warn when durability is wired on the non-journaling protocol

chatStreamAcp never calls spawnNdjson, so it writes no journal and cannot
recover a run on reconnect, but protocol defaults to 'acp' and durability
is silently accepted regardless. An app that wires withSandbox({ runs,
durability }) without explicitly choosing protocol: 'streaming-json' now
gets a one-time logger.warn naming the consequence (unrecoverable run) and
the fix, instead of silently losing recoverability.

This is a warn, not a throw, matching the InMemoryLockStore precedent in
ai-sandbox/middleware.ts: wiring durability once at the middleware level
while deliberately using ACP for runs that don't need recovery is a
legitimate configuration, not an error.

* docs(ai): run-record comments describe what Phase 3 actually writes

sandboxKey/detachedSince field comments claimed they were unpopulated
"in a later phase"; withSandbox's detach path in ai-sandbox writes both
today. Reconcile with the (correct) note on listReclaimable, and stop
citing a reclaimAbandonedRuns reaper and a detachedRunTtl reaper that
do not exist in the codebase.

* fix(ai-sandbox): a hopeless attach fails instead of hanging

An attach for a runId with no journal created an empty one
(`journalFollowCommand`'s `: >> file`, which exists so a legitimate
attach can race the driver's first write) and tailed it forever: no
sentinel, no error, no timeout. Add an attach-only preflight that
consults the authoritative run record — unknown runId and terminal run
fail fast; a live or detached run gets a BOUNDED wait with a
diagnosable timeout.

Also make an alignment divergence that is purely a `threadId` mismatch
say so, instead of reporting a generic `JournalReplayDivergedError`
that sends the reader hunting for non-determinism in the translator.

* docs(ai-sandbox): skills teach takeover and the current run surface

* feat(ai-sandbox): an attaching durable run must reuse the record's threadId

Every journaling harness adapter resolved the thread id as `options.threadId
?? this.generateId()`, and that id is stamped on EVERY emitted chunk. So an
attach whose `drive` callback forgot to forward the run record's `threadId`
silently minted a fresh one, and alignment against the stored log failed at
index 0 — mid-stream, after the takeover had already claimed the run and the
agent had spent tokens. e274a11fd could only diagnose that (via
`JournalReplayThreadIdMismatchError`); this fixes the cause.

Add `resolveDurableThreadId`, the sibling of `resolveDurableRunId`, and wire it
at all four call sites (`ai-codex`, `ai-claude-code`, and both of
`ai-grok-build`'s protocol paths). It throws `DurableThreadIdRequiredError`
only in the durable-AND-attaching quadrant: a durable FRESH run legitimately
mints its `threadId` — it is the run that establishes it — so the other three
quadrants keep the generated fallback and are byte-identical to before.

`ai-acp` and `ai-opencode` are untouched: both are scoped to `durable: false`
with no journaling, so neither has an attach path.

Two pre-existing tests (`codex`/`claude-code` "does NOT start an agent when
attaching") drove an attach with no `threadId` — a configuration that could
never have aligned. Each now passes one, which is what a real attach always
has.

* docs(sandbox): document the attaching-run threadId requirement

* test(e2e): cover durable runs, takeover, and cancel-vs-disconnect

* test(ai-sandbox): takeover conformance against real providers

The takeover unit tests all drive fakes, and fakes have already been wrong
three times about this shell (base64 never streaming on a live pipe, `tail -f`
exiting on a missing file, a kill that misses a grandchild). This adds a
shipped conformance suite that asserts the four properties a takeover rests on
through a provider's REAL spawn/exec against a REAL journal file, and runs it
on local-process and Docker:

1. a takeover delivers the run's sequence exactly once — asserted as a
   transcript, so a replay-everything takeover fails instead of passing a
   "chunks arrived" check;
2. the attach preflight decides (unknown-run / terminal-run), tolerates a
   journal that appears late, and otherwise fails on a bound rather than
   hanging;
3. the epoch fence and its shared latch hold under real concurrency: the
   loser appends nothing, not even its recovery RUN_ERROR, and cannot
   terminalize the record the winner completed;
4. a terminal run's journal and stderr sidecar are really deleted, and the
   attach that follows reports terminal-run.

Exported from the existing `@tanstack/ai-sandbox/testkit` subpath, so
third-party providers can run it too. The Docker gate renders as a NAMED
`unsupported: no Docker daemon reachable` skip when no daemon is present,
never a silent pass.

* fix(ai-sandbox-local-process): killTree verifies the tree is gone

`killTree` ran `taskkill /PID <sh> /T /F` and returned as soon as `spawnSync`
reported no `error` — treating "I successfully asked" as "it died". It never
checked the exit status, and `taskkill /T` could not reach the process anyway:
MSYS's fork emulation runs the last command of a statement list (the `tail -f`
behind a journal follow read) under an intermediate shell that immediately
exits, Windows never reparents, and `/T` walks only live parent links. It
misses the survivor and still exits 0 — which is why an exit-status check alone
would not have caught it. Measured: 2 leaked processes per run of the journal
conformance suite, 4 per run of the takeover suite, accumulating for the life
of the machine; streaming was unaffected, so nothing ever went red.

killTree now snapshots MSYS's own process table (which keeps the logical
parentage) BEFORE killing — the taskkill destroys the only link back to our
shell — then verifies each descendant is gone and kills the survivors directly.
Both suites now leak 0. "Already exited" is classified as success, not retried
and not reported. Teardown stays total by construction: failures are reported
through a new optional `logger`, never thrown, since a throwing kill would
strand a run mid-flight with its readers parked. The POSIX path is unchanged.

* fix(ai): a detached run's log stays open for takeover

The durable delivery sink appended a synthetic terminal RUN_ERROR
("Request aborted") and called durability.close() on EVERY abort, so a
plain disconnect of a detachable run terminalized the very log the
takeover has to continue: a later attach replayed the prefix and stopped,
and the stored RUN_ERROR - a chunk the journal replay cannot reproduce -
diverged alignToStoredLog, which recorded the healthy detached run as
'failed' and appended a second terminal error.

The sink now consults the run's own abort verdict. withSandbox.onAbort
publishes the new RunDetachedCapability on its detach branch (it is the
only actor that has resolved both cancel bands plus detachOnDisconnect),
and core carries the fact to the transport on the stream object itself -
the seam an application cannot forget to wire, since passing chat()'s
stream to the response helper is already mandatory.

Only a plain, intentless disconnect of a detachable run is spared. An
explicit cancel in either band, a non-detachable disconnect,
detachOnDisconnect:false, a genuine provider failure, and a normal finish
all terminalize and close unchanged, so no run is left with an open log
and no successor. Core additionally refuses to spare an abort carrying
RUN_CANCEL_REASON whatever a middleware claims.

* fix(ai-sandbox): journal filenames are injective in the runId

`_` was both a "safe" pass-through character and the escape prefix for
every unsafe byte, so an escaped byte could collide with a literal
escape sequence typed by a different runId (e.g. `encodeRunId('@')`
and `encodeRunId('_40')` both produced `_40`). Two distinct runIds
could therefore share one journal file.

- Drop `_` from the safe-character set so it is only ever an escape
  prefix, making the mapping injective.
- Guard against Windows-reserved device names (CON, PRN, AUX, NUL,
  COM1-9, LPT1-9), which stay reserved even with a `.ndjson`/`.err`
  extension.
- Bound the encoded token length, pairing a truncated prefix with a
  SHA-256 hash of the full runId so truncation cannot itself collide.

Breaking change for any journal written under the old scheme (a runId
containing `_` now encodes differently); no changeset since durability
has not shipped publicly.

* docs(e2e): the takeover precondition is a real disconnect, not a gap

* feat(ai-sandbox): reclaim a terminal run's sandbox from its recorded key

* test(ai-persistence): conformance pins the reclaim cutoff and re-attach drop-out

Hardens the shared listReclaimable conformance case ahead of Phase 4's reaper,
which will act on this method's output (cancelling runs, destroying sandboxes).
Adds four assertions: ttlMs:0 pins the inclusive cutoff (detachedSince === now
is reclaimable); update(runId, { detachedSince: undefined }) must drop a run
out of the list (the re-attach case - a SQL SET-clause builder that filters
undefined out of the patch keeps the old value and the reaper cancels a run
someone is watching); no terminal status ever appears; and 'interrupted' never
appears since the documented predicate is status === 'running'.

* feat(example): the SQLite backend implements listReclaimable

Implements runs.listReclaimable on the ts-react-chat node:sqlite backend and
removes it from the conformance testkit's skipMethods, so the shared
listReclaimable assertions (including the re-attach drop-out case, which
guards against the reaper cancelling a run someone is actively watching) run
against a real SQL query layer instead of being skipped. Adds a SQL-specific
test pinning that a NULL detached_since column is never returned.

* feat(ai-sandbox): journal listing, exit probe, and fail-closed decode

Four additions the Phase 4 reaper and journal sweep both need, all pure
string composition in journal.ts:

- journalListCommand(dir): `ls -1 <dir> 2>/dev/null`. The stderr silencer is
  load-bearing — Daytona's exec and the Sprites fast path fold stderr into
  stdout, so an `ls: cannot access` diagnostic would parse as a filename the
  sweep then tried to decode and delete.
- journalMtimeListCommand(dir) + parseJournalMtimeListing(): `stat -c '%Y %n'`
  with the directory as its own first operand, so the directory's own line is a
  witness that the mechanism ran. BusyBox exits 1 with EMPTY stdout on an
  unrecognized flag, and `stat` on an empty directory also exits 1 — the
  witness line, not the status, is what makes 'no files' distinguishable from
  'unavailable'. Returns a discriminated result, never [], so a caller cannot
  read 'mechanism absent' as 'nothing here is recent' and delete everything.
  find -newermt / -printf are unusable: GNU-only, absent from BusyBox 1.37.
- journalExitProbeCommand() + parseJournalExit(): the byte-identical bounded
  read idiom to journalStderrReadCommand, pointed at the journal, so a reaper
  can learn whether a run reached its {"__exit":N} sentinel WITHOUT driving it
  (driving writes a terminal status and drops a healthy mid-flight run out of
  listReclaimable forever).
- decodeJournalRunId(): reverses the filename encoding and FAILS CLOSED. `_`
  must be followed by exactly two hex digits, decoding is fatal-mode UTF-8, and
  any name that could be encodeRunId's length-capped output is refused as
  'truncated' — that branch is lossy and its output is indistinguishable from a
  legitimately encoded id, so decoding it would name a wrong runId and the
  sweep would delete a possibly live run's journal.

* feat(ai-sandbox): prune journals nobody will read, fail closed

* feat(ai-sandbox): reap detached runs without driving live ones

Sweep the detached runs `RunStore.listReclaimable` surfaces: save each
finished run's transcript, terminalize its record, and reclaim its sandbox.

The obvious design — hand each candidate to `pipeToRunLog` under a short
`runBudgetMs` and see whether it terminalizes — was measured and is broken.
`pipeToRunLog` is total: it ALWAYS writes a terminal status and ALWAYS calls
`durability.close()`. A signal-aware `drive` exits its loop NORMALLY on abort,
so a healthy mid-flight run gets recorded `'completed'` with a `finishedAt`,
its open-for-takeover log closed, and it drops out of `listReclaimable`
forever — a false transcript plus a sandbox that can never be reclaimed.

So sentinel-reached is detected OUT OF BAND, through the in-sandbox journal
(`probeRunExit`, over `journalExitProbeCommand`/`parseJournalExit`), and
`pipeToRunLog` is entered only for a run already known finished or one whose
TTL expired (terminal either way). `runBudgetMs` becomes a safety net whose
expiry is a genuine anomaly. There is no 'still-running' outcome: it is
unreachable by construction.

The probe is injected like `reclaim` is: after a detach nothing appends to the
delivery log, so `snapshot()` can only say "no news", and `SandboxInstanceStore`
has no `list` with which to resolve a handle.

Ordering that is load-bearing rather than incidental: expiry is classified
first (an expired run needs no probe); otherwise `hasFinished` runs BEFORE
anything is touched, and `'producing'`/`'unknown'` return having made no claim,
no append, no record write and no `close()`; `requestRunCancel` precedes the
drive on the expired path so teardown destroys rather than detaching twice;
both authoritative seams are fenced as `driver.ts` composes them; and `reclaim`
runs only after terminal, fed the ORIGINALLY LISTED record — `finish`'s
degraded path rebuilds one without `sandboxKey`.

`detachedSince` is never cleared: it is the field the reaper selects on.
Never rejects — it runs from a cron with nobody to catch it.

* docs: the reaper ships — retire the claims that it does not

* feat(ai-sandbox): export the reaper, sweep, and reclaim surface

* chore: changeset for the durable-run reaper

Adds a Phase 4 changeset for reapDetachedRuns/pruneJournals/reclaimSandbox
(@tanstack/ai-sandbox, minor) and qualifies durable-agent-runs-takeover.md's
claim that the TTL reaper reclaims a stuck detach - true only once the
application schedules it.

* docs(sandbox): document reaping and retention

Adds docs/sandbox/reaping.md: why the reaper is correctness (withPersistence
saves in onFinish, so a run that completes while detached never lands its
transcript until something drives it to terminal), reapDetachedRuns' outcomes
and its one rule (never drive a run to find out whether it finished), why
hasFinished is injected, probeRunExit, pruneJournals' fail-closed table,
reclaimSandbox/sandboxReclaimer's two load-bearing orderings, detachedRunTtl
sizing, the retention split, ready-to-paste Node/Vercel Cron/Cloudflare alarm()
schedules plus the client half, and the no-instance-store-list limitation.

Also fixes the RunDetachedCapability snippet in takeover.md, which referenced a
bare `ctx` and failed test:kiira (TS2304), and retires journal.md's claim that
the journal sweep does not exist yet.

* test(ai-sandbox): reaper and sweep conformance against real providers

* fix(ai-sandbox): thread the offset type through driver and reaper options

`StreamDurability<TOffset extends string = string>` is generic in its offset
type, but `SandboxRunDriverOptions.durability` and `ReapOptions.durability`
hardcoded the `string` default. `StreamDurability` is CONTRAVARIANT in
`TOffset` (`read` takes an offset), so a backend that brands its cursors was
not assignable:

    error TS2322: Type '(runId: string) => StreamDurability<DurableStreamOffset>'
      is not assignable to type '(runId: string) => StreamDurability<string>'.
        Types of property 'read' are incompatible.
          Types of parameters 'offset' and 'offset' are incompatible.
            Type 'string' is not assignable to type 'DurableStreamOffset'.

`@tanstack/ai-durable-stream`'s `durableStream` — the Cloudflare-backed
production backend the sandbox docs point at for multi-host durability —
therefore could not be handed to `sandboxRunDriver` or `reapDetachedRuns`
without an `as unknown as` cast, which is a lint ERROR under `src/**`. The
documented production path was not type-checkable, which is why the reaping
and takeover docs fall back to `memoryStream` in every snippet.

Threads `TOffset` through instead of widening `DurableStreamOffset` to
`string` (which would delete the type safety that makes `resumeFrom`/`read`
offsets meaningful): `RunDeps`, `PipeToRunLogOptions`, `pipeToRunLog`,
`RunController`, `AlignToStoredLogOptions`/`alignToStoredLog`,
`awaitLogQuiescence`, `fenceDurability`, `SandboxRunDriverOptions`/
`sandboxRunDriver`, and `ReapOptions`/`reapDetachedRuns`. Every parameter
defaults to `= string`, so no existing call site changes. `fenceRunStore`
needed nothing — it wraps a `RunStore`, which carries no offset. `finish`'s
internal context is narrowed to `Pick<StreamDurability, 'close'>`, the only
member it touches and the one member that is offset-free.

Types only; no runtime behavior changed.

* test(e2e): cover the reaper, TTL expiry, and replay divergence

`reapDetachedRuns`, `probeRunExit`, `pruneJournals` and `sandboxReclaimer` had
no coverage at the HTTP boundary, and `JournalReplayDivergedError` had zero
references anywhere under `testing/e2e/`. Extends the existing durable-takeover
harness rather than adding a second one.

Harness (`api.durable-takeover.ts`):

- `POST ?action=reap&runId…&now&runBudgetMs` runs ONE sweep plus the journal
  sweep, over exactly the runIds named. Scoping the candidate list is required,
  not cosmetic: the suite is fullyParallel over one shared run store, so an
  unscoped sweep would cancel and drive other tests' detached runs.
- `now` is injected on the sweep and `detachedSince` on `?action=seed`, so both
  sides of the reaper's `detachedSince <= now - ttl` come from the test — the
  TTL boundary is exact with no fake clock.
- The fake sandbox's `process.exec` now answers the four journal-directory
  commands `journal.ts` composes, so `probeRunExit` and `pruneJournals` run for
  real against a journal file derived from the same counter `?action=tick`
  advances.
- `?nondeterministic=1` makes a run's translator mint a non-run-scoped message
  id, so an attach's replay diverges and the alignment guard is provoked over
  HTTP.
- `?action=state` gains journal-file existence, the saved transcript, and
  RUN_ERROR messages. `TEXT_MESSAGE_CONTENT.content` now carries the accumulated
  text instead of a second copy of the delta, which is what makes the saved
  transcript assertable.

Specs — every reaper case sweeps two runs in one pass and asserts both halves,
because a sweep over an empty candidate list satisfies every "did not act"
assertion on its own:

- finalizes a run that reached its sentinel while detached (the transcript
  lands, which is the entire correctness argument for `finalized`) and leaves a
  still-producing one untouched, including its journal file.
- expires a still-producing run past its TTL — cancel recorded, sandbox
  destroyed, agent killed, nothing synthesized into the log — and leaves a
  fresher one alone.
- the TTL cutoff is inclusive: exactly at it expires, one millisecond inside it
  is untouched.
- a non-deterministic replay fails the attach loudly instead of delivering the
  prefix twice, with the aligned control run as the differential.

* fix(ai-sandbox): withSandbox accepts a branded durability offset

`facf99b7d` threaded `TOffset` through `sandboxRunDriver` and
`reapDetachedRuns`, so the RESUME half of a durable app could be wired with
`@tanstack/ai-durable-stream`'s `durableStream`. `SandboxDurabilityOptions.adapter`
still hardcoded `StreamDurability`, so the half that STARTS the run could not:

    error TS2322: Type 'StreamDurability<DurableStreamOffset>' is not assignable
      to type 'StreamDurability<string>'.
      Types of property 'read' are incompatible.
        Types of parameters 'offset' and 'offset' are incompatible.
          Type 'string' is not assignable to type 'DurableStreamOffset'.

i.e. `withSandbox(sandbox, { runs, durability: { adapter } })` — the POST-handler
wiring every durable app starts with, and the first snippet in
`docs/sandbox/takeover.md` — rejected the very adapter the attach route accepted.

Threads `TOffset` through `SandboxDurabilityOptions`, `resolveSandboxDurability`,
`SandboxMiddlewareOptions`, and `withSandbox`, each defaulting to `= string` so
no call site changes.

The resolved payload is NOT parameterized, deliberately.
`createCapability<T>()` takes the value type as a plain type argument and
TypeScript has no higher-kinded types, so a capability has exactly one
instantiation; `SandboxRunDurability` is published on the bus through one, and
no concrete `StreamDurability<X>` is a supertype of every branded backend.
`read` is the sole member that takes an offset IN — every other member mentions
it only in a return position — so the payload's log is typed as the new
`SandboxDurabilityLog` (`Omit<StreamDurability, 'read'>`), which every
`StreamDurability<TOffset>` IS assignable to. No cast, no `any`, no unsoundness.

Dropping `read` costs nothing because of what the seam is, not luck: the bus is
the journal/alignment seam, and `alignToStoredLog` reads the stored prefix via
`snapshot()` — never `read()`, which tails an open log forever (its own docs say
so). Replay by offset belongs to the delivery seam, which receives the
application's adapter directly with its brand intact. `AlignToStoredLogOptions.durability`
is correspondingly narrowed to `Pick<…, 'snapshot'>`, the one member the
transform touches — the same technique `facf99b7d` used for `finish`'s
`Pick<StreamDurability, 'close'>`. `buildEnsureCtx` takes
`Pick<…, 'instances' | 'locks'>` for the same reason: both seams are offset-free.

Types only; no runtime behavior changed.

* fix(ai-sandbox): an aborted drive is never recorded as completed

`pipeToRunLog` checked its abort signal only inside the per-chunk loop, so an
abort that arrived BETWEEN chunks — or a producer that reacted to the signal by
ending its stream, which is what `chat()` does — let the loop exit normally and
fall through to `finish(ctx, 'completed')`. The run was recorded as having
completed successfully with a `finishedAt` it never earned.

Measured on the reaper's TTL-expiry path, the one path that deliberately drives a
live run: a run `reapDetachedRuns` had force-expired, and whose sandbox it had
already destroyed, came back as `{"status":"completed"}`. It is not a reaper
quirk — any caller whose producer ends its stream on abort reached the same gap,
a takeover whose claim is lost mid-drive included — so the check belongs in the
shared seam and not in `reapOne`.

The signal is now re-checked after the loop, before the success path. Every other
distinction is preserved: a producer that throws still records `'failed'` from
inside the `catch`, a genuine completion still records `'completed'`,
`pipeToRunLog` still cannot reject, and `durability.close()` still runs on every
exit path.

With the status honest, `budget-exceeded` is narrowed to the finalization path.
It is documented as an ANOMALY meaning the journal read, translation, or log is
misbehaving, which holds only where the probe already said the agent finished. On
the expiry path there is no probe and nothing polls the cancel the reaper records
(`wasCancelRequested`'s only reader is `withSandbox`'s `onAbort`), so
`runBudgetMs` is the sole thing that stops a still-producing agent — the designed
stop, now reported as `'expired'` with `status: 'aborted'`.

`durable-takeover.spec.ts` pinned the gap behind `EXPIRED_LIVE_OUTCOME` and
asserted terminal-but-not-`running`, which `'completed'` satisfies too; it now
asserts the exact status.

* docs(sandbox): show the durable backend now that it type-checks

Two prior commits threaded a TOffset generic through withSandbox's
durability option, sandboxRunDriver, and reapDetachedRuns, so
durableStream()'s branded StreamDurability<DurableStreamOffset> is now
assignable everywhere without a cast. Swap takeover.md's POST/GET
handlers and reaping.md's durabilityFor from memoryStream to
durableStream and correct the "swap in for production" comments that
were only true because the cast used to be unavoidable.

* fix(ai-persistence): a paused run is not terminalized by a disconnect

* fix(ai-durable-stream): a takeover's appends continue the log instead of colliding

* fix(ai-sandbox): the reaper cannot discard a transcript or poison a live run

* fix(ai): a detach verdict wins over an intermediate terminal chunk

* fix(ai): isTerminalRunStatus ignores the prototype chain

`status in TERMINAL` walked the prototype chain, so a store row whose
`status` column held `'toString'` or `'constructor'` was reported TERMINAL.
Every value reaching the guard comes off a user-implemented RunStore — JSON
out of D1, a Durable Object, Postgres — and nothing validated it there, so the
`RunStatus` type was only a claim. A false `true` is destructive:
`@tanstack/ai-sandbox`'s journal sweep DELETES a terminal run's journal, making
a live run unresumable with no undo; `attach-preflight` fails the attach as
'terminal-run'; and core's resume driver refuses to drive it.

- `Object.hasOwn(TERMINAL, status)`, keeping the `Record<TerminalRunStatus, true>`
  exhaustiveness trick that makes a new terminal status a compile error.
- New exported `isRunStatus` guard so a backend can validate a row at
  deserialization, used at core's only store-status read (the resume driver,
  which now refuses an unrecognized status and logs on the errors channel).
- DetachableRunCapability and RunDetachedCapability are `createCapability<true>()`
  rather than `<boolean>`: absence is the documented negative, so a published
  `false` had no meaning yet was representable — and a consumer testing
  PRESENCE would have read it as the positive.

* fix(ai-sandbox): bootstrap fails fast and a failed destroy is reported

* fix(docs): the reference adapter clears all four durable fields

* test(ai-sandbox): the journal testkit cannot pass a case it did not run

Four fixes to the published conformance testkit, where a vacuous case tells a
third-party provider author their integration is correct when it was never
tested.

1. The two follow cases in `journal-conformance.ts` ran ONE assertion derived
   from their own branch condition and returned, skipping every real assertion
   — including the `firstLineMs < 3_000` incremental-delivery bound, the entire
   reason the follow path exists — while printing a green tick and a title
   claiming the property was verified. The strategy needs a live handle, so it
   is now DECLARED (`followUnsupported`) and the cases register as named
   `it.skip`s carrying the reason. The declaration is checked against a live
   handle in a case that always runs, in BOTH directions, so a config that
   does not describe the provider fails instead of silently dropping coverage
   (and a provider with `backgroundProcesses: false, killableProcesses: true`
   no longer fails `toBe(false)` for a reason unrelated to journaling).
   `expect.hasAssertions()` added to the cases whose assertions were
   conditional.

2. `reaper-conformance.ts`'s module doc claimed the canary proves a quoting bug
   is arbitrary command execution. It cannot see one: with `shellQuote` reduced
   to the identity while `encodeRunId` stays, every path is a single shell word
   of `[A-Za-z0-9._/-]`, no canary fires, and nothing in any real-provider
   suite changes. The doc now says what the canary proves (an ENCODING bug
   reaches the shell) and names the exact-string unit tests that pin the
   quoting, so nobody "simplifies" `shellQuote` away trusting a green canary.

3. `journalFollowCommand` (three path interpolations, `;`-joined prep) never
   met a hostile runId on any real provider, and `journalStderrReadCommand` was
   invoked from no conformance suite at all. Both are now exercised inside the
   existing hostile-runId case, where the id and a live canary already exist,
   with the canary re-asserted after each.

4. The `< 4_000ms` attach-preflight bounds measured one provider round-trip,
   not the preflight — a `docker exec` under parallel load produced `expected
   9652 to be less than 4000`. Re-anchored on the number of `exec` probes the
   preflight makes (exactly one, then the record), which separates "decided
   from the store" from "waited it out" exactly and does not depend on daemon
   latency.

* fix(docs): the takeover example addresses one stream, and read ends on close

`durableStream` derives its backend stream name from the run id it reads off
`?runId` on the request URL, and nothing else. A POST from
`@tanstack/ai-client` keeps its URL byte-identical to a plain chat request and
carries the run id in the AG-UI body (and in `X-Run-Id`), so
`durableStream(request, ...)` on a producing route fell back to
`crypto.randomUUID()`. The takeover page therefore had its POST writing to a
random one-off stream while its GET attached to `agent-runs/<runId>` -- the
attach tailed a stream nobody ever wrote to. It type-checked, which is how it
shipped.

takeover.md now states how stream identity is derived up front, and resolves the
log through one `durabilityFor(runId, request)` helper used on every route, so
producing and attaching provably name the same stream. The helper pins `?runId`
onto a copy of the real URL and carries `Last-Event-ID` across, which also fixes
the mid-stream SSE reconnect that previously failed with "resume offset requires
a runId". The sibling helper in reaping.md is corrected too: its comment claimed
every adapter reads the run id from `X-Run-Id` or `?runId`, which is true of
core's `memoryStream` via `resolveResumeRunId` but not of `durableStream`.

The custom-adapter contract told third-party `StreamDurability` authors that
`read` stops at the first `RUN_FINISHED` / `RUN_ERROR`. An agent-loop run emits
one per iteration, so an adapter written to that rule truncates every resumed
tool-calling run at its first tool call. The real rule -- `read` ends when the
log is closed -- is now stated with the invariant quoted from
packages/ai/src/stream-durability.ts and the agent-loop reason spelled out, and
the reference implementation no longer returns on a terminal chunk.

Also: journaling is opt-in, not unconditional. journal.md, harnesses.md,
overview.md and durability.md described a journal file their own
`withSandbox(sandbox)` examples never create -- `journalOptionsFor` answers
`undefined` unless both `runs` and `durability` are wired. The prose now says so,
and both flagship snippets actually wire it.

* fix(ai-sandbox): the exit sentinel is unforgeable and no attach hangs forever

The agent's stdout and the `{"__exit":N}` sentinel are redirected into the
same unframed journal file, so any agent line carrying `__exit` — an echoed
fixture, a dumped file, printed diagnostics — was a valid sentinel.
`parseJournalExit` took the FIRST such line anywhere in the tail window and
coerced a non-number code to 0, so `probeRunExit` answered
`{state:'finished'}` for a MID-FLIGHT run and `reapOne` then drove it and
reclaimed its sandbox out from under a live agent — the one answer that
module promises never to give.

The sentinel now carries a per-run nonce (`__nonce`), a domain-separated
SHA-256 of the runId, derived in `journalPaths` and carried on
`JournalPaths` so every producer and reader already threads it. Derived
rather than random because a successor host must recompute it from the run
record alone. `parseJournalExit` scans from the END of the window (the shell
writes the real sentinel after all agent output), requires the nonce, and
refuses a non-integer code instead of reporting success. `parseExitSentinel`
is the single per-line test both the reaper's tail probe and the streaming
reader apply.

The attach preflight failed OPEN on an unusable existence probe, which did
not preserve a working attach: it handed control to a reader whose first act
is `: >> journal`, which manufactured an empty journal and tailed it
forever — and self-perpetuated, because `test -f` then succeeded and every
later attach short-circuited and hung too. An unusable probe now falls
through to the bounded wait and reports `journal-timeout` naming the probe.

The reader is bounded on its FIRST byte on both strategies, so an existing
but silent journal (a reader-created empty file, a SIGKILLed agent shell)
raises `JournalAttachUnavailableError` with the new `journal-stalled` reason
instead of tailing forever. That is also what gives the public `readJournal`
its bounded guarantee: it has no `RunStore` and no runId to look one up
with, so it cannot run the preflight at all.

A sentinel-less stream end no longer returns as a truncated success. An
aborted consumer still returns quietly; a stream that died on its own now
throws, matching the shape the unjournaled path uses for a bad exit.

* test(e2e): the harness writes a real exit sentinel

Commit e27374099 made the journal exit sentinel unforgeable, requiring a
nonce keyed to the runId. The durable-takeover harness was still
hand-writing the old {"__exit":0} shape, which the new parser now
correctly refuses to read as a sentinel — so probeRunExit never saw the
run as finished and the reaper's finalize case never fired. Build the
sentinel with the exported exitSentinelLine/journalPaths helpers instead,
the same bytes journaledCommand's printf writes.

* fix(docs): changesets and skills describe what actually ships

Four changesets denied features this release ships; the release notes are
built from all of them together, so each "not shipped yet" claim contradicted
a sibling entry.

- durable-run-journal: cancelRequested/detachedSince/sandboxKey ARE written
  this release; sandboxRunDriver/reapDetachedRuns/pruneJournals all ship. The
  harness adapters now throw DurableRunIdRequiredError instead of falling back
  to a generated id, and pruneJournals bounds the detached-sentinel journals
  the per-run cleanup cannot see.
- durable-run-types: AbortInfo.cancelRequested is populated from the abort
  reason and stream-to-response relies on it; RunDeps.durability is a per-run
  factory and RunController.attach takes (runId, fromOffset, signal?).
- reap-detached-runs: one argument each, ONE listReclaimable call by design,
  and 'budget-exceeded' means the record IS terminal and reclaim fired -- the
  inverse of "left alone". Documents the new 'reclaim-failed' and
  'destroy-failed' arms.
- durable-agent-runs-takeover: ai-opencode does not journal (durable: false),
  so it drops from the journaling list and from minor to patch.

Adds changesets for review fixes in released packages that had none: the
per-middleware terminal-hook guard, isTerminalRunStatus's prototype-chain fix
plus the new isRunStatus guard, and shell.ts's bootstrap failure.

Skills are outside kiira's include, so nothing type-checked these. Every
snippet touched was verified against the packages' built barrels:

- defineChatMiddleware is core's, not @tanstack/ai-sandbox's.
- StructuredOutputMiddlewareConfig extends Omit<..., 'tools'>; there is no
  config.tools.
- hookCtx.args is unknown -- narrowed with typeof/in instead of a cast.
- Inverted safety advice: the terminal hooks are guarded by runTerminalHook;
  onChunk and onConfig are the unguarded ones. The example now points at them.
- "silently falls back" replaced by the loud DurableRunIdRequiredError and
  DurableThreadIdRequiredError throws.
- reapDetachedRuns is wireable: the required hasFinished probe, probeRunExit
  as its implementation, a compiling sample, and docs/sandbox/reaping.md.
- withSandbox's detachedRunTtl and ReapOptions.detachedRunTtlMs documented as
  two separate, unlinked configs, because the parsed middleware value is read
  by nothing.
- library_version matched to each package.json; useChat takes tools, not
  clientTools; the real "override key:" message; seven invariants not five;
  the sqlite example skips only listByThread; withPersistence calls none of
  the optional RunStore methods; RunError is not re-exported from
  @tanstack/ai-persistence.

* fix(ai-durable-stream): resolve the runId the same way core does

durableStream read the run id from ?runId only, so a @tanstack/ai-client POST -- whose URL stays byte-identical to a plain chat request, with the run id in X-Run-Id -- produced into a random-UUID stream the GET attach route could never address, and a Last-Event-ID reconnect with no ?runId tripped the resume guard.

Resolution now goes through core's resolveResumeRunId (X-Run-Id first, then ?runId), the same single implementation memoryStream and the resume response helpers use. A request naming no run at all throws instead of minting a random id.

* docs(sandbox): the sentinel is nonced and the reaper has two more outcomes

* fix(ai-sandbox)!: the reaper's TTL is the only TTL

`SandboxDurabilityOptions.detachedRunTtl` was validated by `parseRunTtlMs` at
setup and published as `SandboxRunDurability.detachedRunTtlMs` on the capability
bus, where nothing read it. Nothing COULD read it: the only actor that enforces
a detached-run TTL is `reapDetachedRuns`, which runs from a cron with no chat in
flight, so it has no `CapabilityContext` and cannot reach the bus at all. It took
its own required `ReapOptions.detachedRunTtlMs` instead — a different option, in
different units, at a different call site.

So the middleware option was ceremony with a validator attached: it failed
loudly on a typo, which only reinforced the false impression that it was
load-bearing, and changed nothing. Worse, the two knobs diverged silently —
`detachedRunTtl: '30m'` on `withSandbox` beside `detachedRunTtlMs: 5 * 60_000`
on the sweep expired runs at five minutes while the config claimed thirty.

Removed the option, its `parseRunTtlMs` parse call, the parser itself (no other
caller), `DEFAULT_DETACHED_RUN_TTL`, and the payload field. The reaper's required
`detachedRunTtlMs` is now the single source of truth, and its inclusive `<=`
cutoff is untouched. The fail-loud property survives in a stronger form: a plain
required `number` does not accept `'30min'`, so a typo is a compile error, not a
runtime one.

Also corrected `cancel.ts`, which asserted `reapDetachedRuns` recovers a
degraded cancel once "`detachedRunTtlMs` (its parsed `durability.detachedRunTtl`)"
elapses — describing a derivation that never existed.

Breaking on an UNPUBLISHED surface: `packages/ai-sandbox/src/durability.ts` is
absent from `@tanstack/ai-sandbox@0.2.4`'s tree, so no released API changes.

* docs(sandbox): drop the runId workaround now that the adapter resolves it

durableStream now resolves the run id via X-Run-Id header first, then
?runId (resolveResumeRunId), matching memoryStream, and throws when a
request names neither. The durabilityFor helper that force-rewrote the
request URL to carry ?runId existed only to compensate for the old
query-only resolution and is now dead weight; both the POST and GET
routes on the takeover page pass durableStream(request, options)
straight through. The custom-adapter tutorial's crypto.randomUUID()
fallback is replaced with the same throw, so it no longer teaches a
pattern the shipped adapter has abandoned.

* test(sandbox-providers): assert behavior instead of constants

Two provider tests asserted values that could not fail, and replacing them
with behavioral assertions exposed that `killableProcesses: true` was false
on BOTH providers.

docker
- snapshot: asserted the shape of a tag composed BEFORE `container.commit()`
  ran, so deleting the commit still passed. Now inspects the image (this was
  the package's only snapshot coverage) and cleans it up.
- kill: replaced a read of the `killableProcesses` module constant with an
  end-to-end case that spawns a process, kills it, and asks the container's
  own `ps` whether it died. It did not — `stream.destroy()` only detaches the
  client while Docker leaves the exec's process running, so a spawned `sleep`
  survived `kill()` until the container was removed. Fixed by having the
  wrapper record its pid and signalling it inside the container (process group
  first, so background grandchildren are reached, then escalating to KILL);
  wired into `kill()` and both `exec`/`spawn` abort paths. Aborted `exec` also
  never settled, because a destroyed stream emits only `close`.

local-process
- Added the missing end-to-end death assertion for the POSIX
  `child.kill(signal)` branch (skips on Windows with a named reason). It fails
  on the old code: `sh -c '<cmd>'` does not reliably exec its command, so
  killing the `sh` leaves the command alive — verified on Linux, where
  `sh -c 'sleep N'` + `child.kill('SIGKILL')` leaves `sleep` running. Fixed by
  spawning `detached` and signalling the process group.
- Widened the already-exited taskkill classification to cover `/T`'s
  "no running instance of the task" wording, which matched neither existing
  pattern and so logged a warning on an ordinary teardown race. Genuine
  refusals (`Access is denied.`, critical system process) stay failures.
- The unpaired `expect(warnings).toEqual([])` now captures each warning WITH
  its meta, so a recurrence prints the raw taskkill status/stderr instead of
  just a message, and is paired with a case that produces a real refusal and
  asserts the warning IS emitted.
- Gave two process-spawning tests explicit 30s timeouts (matching their
  siblings) and widened an EBUSY teardown retry: they were inheriting deadlines
  that measured machine load, not behavior.

* fix(adapters): drop the removed detachedRunTtlMs from durability fixtures

`cd3446f3c` removed `SandboxRunDurability.detachedRunTtlMs`, leaving five
harness-adapter fixtures constructing the capability payload with a property
that no longer exists. Three were hard TS2353 excess-property errors:

  ai-claude-code/tests/fakes.ts:44
  ai-codex/tests/attach.test.ts:162
  ai-codex/tests/translate-determinism.test.ts:229

The other two are in `ai-grok-build`, whose tsconfig `include` is `[src/**/*]`
— so its tests are never type-checked and the same stale property was inert
there rather than fatal. Removed anyway, since it describes a field the reaper
alone owns.

* feat(ai-sandbox): export encodeRunId and a no-replay attach error

Adds `DurableAttachNotSupportedError` (durability.ts, barrel-exported as a
VALUE next to its two id-required siblings) for adapter paths that do not
journal -- `ai-grok-build`'s `chatStreamAcp`, `ai-acp`, `ai-opencode` -- where
`attach: true` cannot replay at all: it re-runs the agent against a workspace
the previous attempt already mutated and double-appends its whole output to
the log. Deliberately not `JournalAttachUnavailableError`, which means a
journal that should exist has not appeared yet, and is retryable.

Exports `encodeRunId` from journal.ts and the barrel so `ai-codex` and
`ai-claude-code` can encode a caller-supplied `runId` before interpolating it
into their own in-sandbox paths (prompt files, MCP bridge config) instead of
duplicating an encoder whose past non-injectivity bug is documented in place.

Also drops the removed `SandboxDurabilityOptions.detachedRunTtl` from the
ai-sandbox skill: the sample no longer type-checks, and the callout telling
readers to keep it in sync with `ReapOptions.detachedRunTtlMs` now names the
reaper option as the only TTL.

* fix(sandbox-providers): kill claims are measured or falsifiable

killableProcesses selects the j…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

persistence Durable chat state: @tanstack/ai-persistence, client persistence, adapters, locks waiting-on: author Waiting for the author to respond or update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants